Show a Reverb node as a device with a round screen of its tail (#389)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 15m49s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 16:35:59 +02:00
1 parent c75e72d4ee
commit e8021b1fd3
11 files changed
+1249 -29

No files matched your search

+11 -1
View File
@@ -1244,7 +1244,17 @@ const REVERB_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default
const PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 };
const REVERB_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.3 };
function reverbRoomOf(settings: PropertyBag): ReverbRoom {
/** The numbers of a Reverb, for its device (#389). */
export const REVERB_ROWS = {
decay: REVERB_DECAY,
damping: REVERB_DAMPING,
predelay: PREDELAY,
mix: REVERB_MIX,
};
export const REVERB_ROOM_KEY = REVERB_ROOM.key;
export const REVERB_IMPULSE_KEY = IMPULSE.key;
export function reverbRoomOf(settings: PropertyBag): ReverbRoom {
return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room';
}
+14 -7
View File
@@ -32,11 +32,19 @@ export const REVERB_PRESETS: Record<'room' | 'hall' | 'cave' | 'plate', ReverbSh
export const REVERB_MAX_DECAY = 10;
/** Where the highs of a fully damped tail have fallen to by its end, and an undamped one stays. */
const OPEN_HZ = 20000;
const DAMPED_HZ = 500;
export const OPEN_HZ = 20000;
export const DAMPED_HZ = 500;
/** A small, fast generator, so every voice gets the same noise and a check the same numbers. */
function noise(seed: number): () => number {
/** Where the highs of a tail are cut `share` of the way through it, 0 to 1, at this damping. */
export function reverbCutoff(damping: number, share: number): number {
return OPEN_HZ * (DAMPED_HZ / OPEN_HZ) ** (Math.max(0, Math.min(1, damping)) * share);
}
/**
* A small, fast generator, so every voice gets the same noise and a check the same numbers; the
* Reverb's device (#389) scatters its reflections with it.
*/
export function seededNoise(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
@@ -54,14 +62,13 @@ function noise(seed: number): () => number {
*/
export function reverbImpulse(sampleRate: number, decay: number, damping: number): Float32Array[] {
const length = Math.max(1, Math.round(Math.min(REVERB_MAX_DECAY, Math.max(0.01, decay)) * sampleRate));
const shut = Math.max(0, Math.min(1, damping));
return [1, 2].map((seed) => {
const next = noise(seed);
const next = seededNoise(seed);
const out = new Float32Array(length);
let low = 0;
for (let i = 0; i < length; i++) {
const t = i / length;
const cutoff = OPEN_HZ * (DAMPED_HZ / OPEN_HZ) ** (shut * t);
const cutoff = reverbCutoff(damping, t);
const keep = Math.exp((-2 * Math.PI * cutoff) / sampleRate);
low = (1 - keep) * next() + keep * low;
out[i] = low * 10 ** (-3 * t);
+44
View File
@@ -7,6 +7,7 @@ import { ChipPanel } from '../soundfx/ChipPanel';
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { GainPanel } from '../soundfx/GainPanel';
import { ReverbPanel } from '../soundfx/ReverbPanel';
import { CurvePanel } from '../soundfx/CurvePanel';
import type { EqEditor } from '../soundfx/EqEditor';
import { rectOf } from '../textProbe';
@@ -366,6 +367,49 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Reverb node shows (#389), in the sound FX graph or the instrument graph: whether it
* is shown and where, its knobs, which settings are idle, the picked type, the sound, what the
* screen draws and says, and the node's ports.
*/
reverb: {
panel: (nodeId: string) => {
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
(c) => c.faceOf(nodeId)?.widget instanceof ReverbPanel,
);
const panel = canvas?.faceOf(nodeId)?.widget;
if (!canvas || !(panel instanceof ReverbPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
idle: [...panel.idle],
room: panel.roomButtons.find((b) => b.picked)?.room ?? null,
rooms: Object.fromEntries(panel.roomButtons.map((b) => [b.room, screenCentre(b)])),
highs: panel.highs.text,
sound: { text: panel.soundButton.text, at: screenCentre(panel.soundButton) },
scope: {
/** How far out the tail ends, 0 to 1 of the outer ring; 0 without a tail. */
reach: panel.scope.reach,
rings: panel.scope.rings,
kind: panel.scope.tail?.kind ?? null,
says: panel.scope.says,
},
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
+145
View File
@@ -0,0 +1,145 @@
import { oneStep } from '../../core/commands/CompoundCommand';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
import { playedSoundById } from '../../core/properties/playedSound';
import { REVERB_IMPULSE_KEY, REVERB_ROOM_KEY, SoundFxReverb } from '../../core/soundfx/nodes';
import type { ReverbRoom } from '../../core/soundfx/reverb';
import { Menu, type Widget } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { NodeParamDrag } from './nodeParamDrag';
import { type ReverbHost, ReverbPanel } from './ReverbPanel';
import {
customFrom,
idleReverbKeys,
type ReverbKey,
type ReverbTail,
reverbValues,
roomTakes,
} from './reverbView';
/**
* A Reverb node drawn as its device (#389): its tail drawn from its settings – or from the peaks of
* the sound it takes – every one of them worked right in the graph. A knob its type does not take
* goes over to Custom with the numbers the type had. It listens to nothing while the sound plays.
*/
export class ReverbFace implements NodeFace, ReverbHost {
readonly widget: ReverbPanel;
private readonly drag: NodeParamDrag;
/** Sounds whose peaks are on their way, so they are asked for once. */
private readonly loading = new Set<string>();
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new ReverbPanel(this);
this.drag = new NodeParamDrag(state, 'reverb-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxReverb.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
const values = reverbValues(node.params);
let tail: ReverbTail | null = null;
let missing = '';
if (values.room !== 'sound')
tail = { kind: 'made', predelay: values.predelay, decay: values.decay, damping: values.damping };
else {
const file = values.impulse ? playedSoundById(this.state.project, values.impulse) : '';
const peaks = file ? soundLibrary.peaks(file) : null;
const seconds = file ? soundLibrary.duration(file) : null;
if (peaks && seconds !== null) tail = { kind: 'sound', predelay: values.predelay, seconds, peaks };
else if (!file) missing = 'NO SOUND';
else if (soundLibrary.failedToLoad(file)) missing = 'CANNOT LOAD';
else {
missing = 'LOADING';
this.load(file);
}
}
this.widget.sync({
values,
wired: this.wired,
idle: idleReverbKeys(values.room),
tail,
sound: values.impulse ? assetRefLabel(this.state.project, values.impulse) : '',
missing,
});
}
/** Loads what the screen draws a sound's tail from, and draws it once it is there. */
private load(file: string): void {
if (this.loading.has(file)) return;
this.loading.add(file);
void soundLibrary.loadPicture(file).then(() => {
this.loading.delete(file);
this.sync();
});
}
tick(): boolean {
return false;
}
hide(): void {}
preview(key: ReverbKey, value: number): void {
const node = this.node();
if (!node) return;
// A knob the type does not take: Custom, from the numbers the knobs showed.
if (this.drag.holds(REVERB_ROOM_KEY) || !roomTakes(reverbValues(node.params).room, key))
this.drag.previewAll(node, customFrom(reverbValues(node.params), key, value));
else this.drag.preview(node, key, value);
}
commit(key: ReverbKey, value: number): void {
const node = this.node();
if (!node) return;
this.preview(key, value);
this.drag.commitHeld(this.assetId, node, 'Turn Reverb knob');
}
pickRoom(room: ReverbRoom): void {
const node = this.node();
if (node && reverbValues(node.params).room !== room)
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, REVERB_ROOM_KEY, room));
}
/** The sounds of the project; picking one makes it the impulse response and the type Sound. */
pickSound(button: Widget): void {
const node = this.node();
if (!node) return;
const values = reverbValues(node.params);
const items = soundChoices(this.state.project).map((sound) => ({
label: sound.label,
current: sound.id === values.impulse,
onSelect: () => {
const commands = [
...(sound.id !== values.impulse
? [new SetNodeParamCommand(this.assetId, this.nodeId, REVERB_IMPULSE_KEY, sound.id)]
: []),
...(values.room !== 'sound'
? [new SetNodeParamCommand(this.assetId, this.nodeId, REVERB_ROOM_KEY, 'sound')]
: []),
];
if (commands.length > 0) this.state.execute(oneStep('Pick Reverb sound', commands));
},
}));
if (items.length > 0) new Menu(items, { searchable: true }).openBelow(button);
}
}
+413
View File
@@ -0,0 +1,413 @@
/**
* 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 });
}
}
+10 -2
View File
@@ -1,6 +1,12 @@
import type { GraphNode } from '../../core/graph';
import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import {
SoundFxCompressor,
SoundFxFilter,
SoundFxGain,
SoundFxParametricEq,
SoundFxReverb,
} from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { ChipFace } from './ChipFace';
@@ -10,8 +16,9 @@ import { EnvelopeFace } from './EnvelopeFace';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { GainFace } from './GainFace';
import { ReverbFace } from './ReverbFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388, #389), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
@@ -20,5 +27,6 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
return new CurveFace(new EqPanel(state, true), assetId, node.id);
if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id);
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
return null;
}
+46 -18
View File
@@ -1,3 +1,4 @@
import { oneStep } from '../../core/commands/CompoundCommand';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
@@ -6,11 +7,12 @@ import type { EditorState } from '../state';
/**
* A number of a node turned by a knob: written into the node as it goes, one undo step when it
* ends. A Compressor's tab (#378) and its node (#380) each have one.
* ends. A Compressor's tab (#378) and its node (#380) each have one. A drag may write several
* settings at once (#389), a Reverb's knob that takes it from a room to Custom.
*/
export class NodeParamDrag {
/** The value a drag began with – its undo step's other side; undefined when none was typed in. */
private before: { key: string; value: PropertyValue | undefined } | null = null;
/** The values a drag began with – its undo step's other side; undefined where none was typed in. */
private readonly before = new Map<string, PropertyValue | undefined>();
private readonly perFrame: FramePreview;
constructor(
@@ -21,37 +23,63 @@ export class NodeParamDrag {
}
preview(node: GraphNode, key: string, value: number): void {
if (this.before?.key !== key) {
this.restore(node);
this.before = { key, value: node.params[key] };
this.previewAll(node, { [key]: value });
}
/** Writes several settings as one drag; one other than the last drag wrote begins a new one. */
previewAll(node: GraphNode, values: Record<string, PropertyValue>): void {
if ([...this.before.keys()].some((key) => !(key in values))) this.restore(node);
for (const [key, value] of Object.entries(values)) {
if (!this.before.has(key)) this.before.set(key, node.params[key]);
node.params[key] = value;
}
node.params[key] = value;
this.perFrame.request();
}
/** Whether the drag going on has written this setting. */
holds(key: string): boolean {
return this.before.has(key);
}
commit(assetId: string, node: GraphNode, key: string, value: PropertyValue): void {
this.commitAll(assetId, node, { [key]: value }, `Set ${key}`);
}
/** What the drag going on wrote, as one undo step. */
commitHeld(assetId: string, node: GraphNode, label: string): void {
const values: Record<string, PropertyValue> = {};
for (const key of this.before.keys()) if (node.params[key] !== undefined) values[key] = node.params[key];
this.commitAll(assetId, node, values, label);
}
private commitAll(
assetId: string,
node: GraphNode,
values: Record<string, PropertyValue>,
label: string,
): void {
this.perFrame.cancel();
this.restore(node);
if (node.params[key] === value) {
this.state.preview();
return;
}
this.state.execute(new SetNodeParamCommand(assetId, node.id, key, value));
const commands = Object.entries(values)
.filter(([key, value]) => node.params[key] !== value)
.map(([key, value]) => new SetNodeParamCommand(assetId, node.id, key, value));
if (commands.length === 0) this.state.preview();
else this.state.execute(commands.length === 1 ? commands[0]! : oneStep(label, commands));
}
/** The node as it was before the drag, which is left without a step. */
restore(node: GraphNode): void {
const before = this.before;
if (!before) return;
if (before.value === undefined) delete node.params[before.key];
else node.params[before.key] = before.value;
this.before = null;
for (const [key, value] of this.before) {
if (value === undefined) delete node.params[key];
else node.params[key] = value;
}
this.before.clear();
}
/** Another node: what was begun on the last one is dropped. */
forget(): void {
this.perFrame.cancel();
this.before = null;
this.before.clear();
}
cancel(): void {
+322
View File
@@ -0,0 +1,322 @@
/**
* What a Reverb's node draws (#389), as numbers: where the parts of its device sit, how its knobs
* turn, which numbers its type takes, and its tail as rings out from the sound – the round screen.
* Kept apart from the drawing so it is checked without a browser.
*/
import type { PropertyBag } from '../../core/model/Properties';
import { REVERB_IMPULSE_KEY, REVERB_ROWS, reverbRoomOf } from '../../core/soundfx/nodes';
import {
DAMPED_HZ,
OPEN_HZ,
REVERB_MAX_DECAY,
REVERB_PRESETS,
REVERB_ROOMS,
type ReverbRoom,
reverbCutoff,
seededNoise,
} from '../../core/soundfx/reverb';
import { formatNumber } from '../../ui/math';
import { secondsText } from './chipView';
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
export type ReverbKey = keyof typeof REVERB_ROWS;
export const REVERB_KEYS = Object.keys(REVERB_ROWS) as ReverbKey[];
/** The numbers a room sets itself, and a sound brings but the pre-delay. */
const SHAPE_KEYS: readonly ReverbKey[] = ['decay', 'damping', 'predelay'];
export interface ReverbValues extends Record<ReverbKey, number> {
room: ReverbRoom;
/** The sound asset that is the impulse response, '' for none. */
impulse: string;
}
const numberOf = (params: PropertyBag, key: ReverbKey): number => {
const value = params[key];
return typeof value === 'number' && Number.isFinite(value) ? value : REVERB_ROWS[key].default;
};
/**
* The numbers a Reverb plays with, as its knobs show them: a room's own decay, damping and
* pre-delay, else the node's, each its row's default where none is typed in.
*/
export function reverbValues(params: PropertyBag): ReverbValues {
const room = reverbRoomOf(params);
const preset = room === 'custom' || room === 'sound' ? null : REVERB_PRESETS[room];
const impulse = params[REVERB_IMPULSE_KEY];
return {
room,
impulse: typeof impulse === 'string' ? impulse : '',
decay: preset?.decay ?? numberOf(params, 'decay'),
damping: preset?.damping ?? numberOf(params, 'damping'),
predelay: preset?.predelay ?? numberOf(params, 'predelay'),
mix: numberOf(params, 'mix'),
};
}
/** Whether a type plays a number as it is set: a room has its own, a sound its own decay and damping. */
export function roomTakes(room: ReverbRoom, key: ReverbKey): boolean {
if (key === 'mix' || room === 'custom') return true;
return room === 'sound' && key === 'predelay';
}
/**
* The settings that do nothing as things stand, greyed as the Chip's are – still to be turned: a
* knob the type does not take goes over to Custom with it. The sound while the type is not Sound.
*/
export function idleReverbKeys(room: ReverbRoom): Set<ReverbKey | 'impulse'> {
const idle = new Set<ReverbKey | 'impulse'>(SHAPE_KEYS.filter((key) => !roomTakes(room, key)));
if (room !== 'sound') idle.add('impulse');
return idle;
}
/**
* What a knob of a type that does not take it writes (#389): Custom, with the numbers the knobs
* showed, so the sound changes by that one knob only.
*/
export function customFrom(
values: ReverbValues,
key: ReverbKey,
value: number,
): Record<string, number | string> {
return {
room: 'custom',
decay: values.decay,
damping: values.damping,
predelay: values.predelay,
[key]: value,
};
}
export interface ReverbKnob {
label: string;
taper: number;
decimals: number;
format: (value: number) => string;
}
export const percentText = (share: number): string => `${formatNumber(Math.round(share * 100), 0)}%`;
/**
* How each knob turns: the decay gives its first seconds most of the turn – a room's 0.6 s sits at
* a third, a hall's 2.5 s past the middle – the pre-delay its first tens of milliseconds.
*/
export const REVERB_KNOBS: Record<ReverbKey, ReverbKnob> = {
decay: { label: 'Decay', taper: 3, decimals: 2, format: secondsText },
damping: { label: 'Damping', taper: 1, decimals: 2, format: percentText },
predelay: { label: 'Pre-delay', taper: 2, decimals: 3, format: secondsText },
mix: { label: 'Wet', taper: 1, decimals: 2, format: percentText },
};
// --- The plate. ---
/** The plate: as the Compressor's (#379), the panel never takes another size. */
export const REVERB_PANEL = { w: 640, h: 280 };
/** The round screen's side, and the radius of its outer ring inside it. */
export const SCOPE_SIZE = 176;
const KNOB_SLOT = 64;
const GROUP_GAP = 22;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
const ROOM_BUTTON = { w: 54, h: 30, gap: 6 };
const SOUND_ROW = 24;
const SOUND_CAPTION = 52;
export interface ReverbLayout {
title: Box;
/** The dark frame round the screen, and the screen in it. */
bezel: Box;
screen: Box;
groups: { label: string; box: Box }[];
rooms: Box[];
/** The picked sound, its name engraved before it. */
sound: Box;
soundCaption: { x: number; y: number };
knobs: Record<ReverbKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function reverbLayout(): ReverbLayout {
const { w } = REVERB_PANEL;
const bezel = { x: 24, y: 44, w: SCOPE_SIZE + 20, h: SCOPE_SIZE + 36 };
const screen = { x: bezel.x + 10, y: bezel.y + 16, w: SCOPE_SIZE, h: SCOPE_SIZE };
// The right of the plate: the types on top, the sound under them, the knobs below.
const left = bezel.x + bezel.w + 24;
const right = w - 24;
const roomsWidth = REVERB_ROOMS.length * (ROOM_BUTTON.w + ROOM_BUTTON.gap) - ROOM_BUTTON.gap;
const roomsX = left + (right - left - roomsWidth) / 2;
const groups: { label: string; box: Box }[] = [
{ label: 'TYPE', box: { x: roomsX, y: bezel.y, w: roomsWidth, h: GROUP_LINE } },
];
const rooms = REVERB_ROOMS.map((_, i) => ({
x: roomsX + i * (ROOM_BUTTON.w + ROOM_BUTTON.gap),
y: bezel.y + GROUP_LINE + 2,
w: ROOM_BUTTON.w,
h: ROOM_BUTTON.h,
}));
const soundY = rooms[0]!.y + ROOM_BUTTON.h + 10;
const sound = { x: roomsX + SOUND_CAPTION, y: soundY, w: roomsWidth - SOUND_CAPTION, h: SOUND_ROW };
const space = SHAPE_KEYS.length * KNOB_SLOT;
const groupTop = soundY + SOUND_ROW + 14;
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
let x = left + (right - left - space - GROUP_GAP - KNOB_SLOT) / 2;
groups.push({ label: 'SPACE', box: { x, y: groupTop, w: space, h: GROUP_LINE } });
const knobs = {} as Record<ReverbKey, KnobSpot>;
for (const key of SHAPE_KEYS) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
groups.push({ label: 'MIX', box: { x, y: groupTop, w: KNOB_SLOT, h: GROUP_LINE } });
knobs.mix = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
return {
title: { x: 34, y: 14, w: 120, h: 24 },
bezel,
screen,
groups,
rooms,
sound,
soundCaption: { x: roomsX + SOUND_CAPTION / 2 - 4, y: soundY + (SOUND_ROW - KNOB_CAPTION) / 2 + 2 },
knobs,
screws: screwsOf(REVERB_PANEL),
};
}
export const REVERB_LAYOUT: ReverbLayout = reverbLayout();
// --- The screen: the tail as rings out from the sound. ---
/** The time at the outer ring: the longest decay after the longest pre-delay. */
export const SCOPE_SECONDS = REVERB_MAX_DECAY + REVERB_ROWS.predelay.max!;
/** The rings drawn faintly as a scale, in seconds; the ones with a number. */
export const SCOPE_GRID = [0.1, 0.5, 1, 2, 5, 10];
export const SCOPE_MARKS = [1, 2, 5, 10];
/**
* How far out a time sits, 0 at the sound to 1 at the outer ring: as the root of its seconds, so a
* room's tail of 0.6 s is a small disc and a cave's of 5 s fills two thirds – every Reverb on the
* same scale, so their sizes compare.
*/
export function scopeRadius(seconds: number): number {
return Math.sqrt(Math.min(SCOPE_SECONDS, Math.max(0, seconds)) / SCOPE_SECONDS);
}
export function scopeSeconds(radius: number): number {
return Math.max(0, Math.min(1, radius)) ** 2 * SCOPE_SECONDS;
}
/** The tail a Reverb rings: made from its decay and damping, or a sound's, known by its peaks. */
export type ReverbTail =
| { kind: 'made'; predelay: number; decay: number; damping: number }
| { kind: 'sound'; predelay: number; seconds: number; peaks: Float32Array };
/** A point of the tail: how loud it still is, 1 at its start to 0 at 60 dB down, and how bright. */
export interface TailPoint {
level: number;
/** How much of the highs is left, 1 all of them to 0 down to 500 Hz; null where a sound tells nothing of it. */
bright: number | null;
}
/** How long a tail rings after its pre-delay. */
export function tailSeconds(tail: ReverbTail): number {
return tail.kind === 'made' ? Math.min(REVERB_MAX_DECAY, Math.max(0.01, tail.decay)) : tail.seconds;
}
/** The cut-off of the highs as a share of the way from 500 Hz up to 20 kHz, in octaves. */
export function brightness(hz: number): number {
return Math.max(0, Math.min(1, Math.log2(hz / DAMPED_HZ) / Math.log2(OPEN_HZ / DAMPED_HZ)));
}
/**
* The tail `seconds` after the sound, or null outside it: a made one falls by 60 dB in its decay,
* evenly in dB as the screen draws it, its highs closing as the impulse response's do. A sound's is
* as loud as its loudest peak there against its loudest of all.
*/
export function tailAt(tail: ReverbTail, seconds: number): TailPoint | null {
const length = tailSeconds(tail);
const into = seconds - tail.predelay;
if (into < 0 || into > length || length <= 0) return null;
const share = into / length;
if (tail.kind === 'made')
return { level: 1 - share, bright: brightness(reverbCutoff(tail.damping, share)) };
const peaks = tail.peaks;
let loudest = 0;
for (const p of peaks) loudest = Math.max(loudest, p);
if (loudest <= 0 || peaks.length === 0) return { level: 0, bright: null };
const peak = peaks[Math.min(peaks.length - 1, Math.floor(share * peaks.length))]!;
const db = peak > 0 ? 20 * Math.log10(peak / loudest) : -Infinity;
return { level: Math.max(0, 1 + db / 60), bright: null };
}
export interface TailRing extends TailPoint {
/** Its radius in pixels. */
r: number;
}
/**
* The tail as rings `step` pixels wide out to `radius`, each as loud and bright as the tail is at
* its time; none where the tail is silent.
*/
export function tailRings(tail: ReverbTail, radius: number, step = 1.5): TailRing[] {
const rings: TailRing[] = [];
for (let r = step / 2; r < radius; r += step) {
const point = tailAt(tail, scopeSeconds(r / radius));
if (point && point.level > 0) rings.push({ r, ...point });
}
return rings;
}
export interface Reflection extends TailPoint {
r: number;
angle: number;
}
/** Reflections scattered over the tail, as many as a dense tail has – the same ones each time. */
const REFLECTIONS = 90;
/**
* Dots over the tail, at angles of their own and more of them the further it has gone, as the
* echoes of a room grow thicker; each as loud and bright as the tail where it sits.
*/
export function reflections(tail: ReverbTail, radius: number): Reflection[] {
const next = seededNoise(389);
const length = tailSeconds(tail);
const dots: Reflection[] = [];
for (let i = 0; i < REFLECTIONS; i++) {
const along = Math.sqrt((next() + 1) / 2);
const angle = next() * Math.PI;
const point = tailAt(tail, tail.predelay + along * length);
if (point && point.level > 0)
dots.push({ r: scopeRadius(tail.predelay + along * length) * radius, angle, ...point });
}
return dots;
}
/** How strongly the tail is drawn, and the sound at its middle: the wet against the dry. */
export function wetShares(mix: number): { tail: number; dry: number } {
const wet = Math.max(0, Math.min(1, mix));
return { tail: Math.sqrt(wet), dry: Math.max(0.2, Math.sqrt(1 - wet)) };
}
/** A frequency as the screen writes it: `500 Hz`, `4.1 kHz`. */
export function hzText(hz: number): string {
return hz >= 1000 ? `${formatNumber(hz / 1000, 1)} kHz` : `${formatNumber(Math.round(hz), 0)} Hz`;
}
/** What the screen writes under it: the pre-delay, where the highs end up, when the tail has died. */
export function scopeTexts(
tail: ReverbTail | null,
missing: string,
): { pre: string; highs: string; ends: string } {
if (!tail) return { pre: '', highs: '', ends: missing };
return {
pre: `PRE ${secondsText(tail.predelay)}`,
highs: tail.kind === 'made' ? `HIGHS ${hzText(reverbCutoff(tail.damping, 1))}` : '',
ends: `ENDS ${secondsText(tail.predelay + tailSeconds(tail))}`,
};
}
/** A mark of the scale as it is written beside its ring. */
export function markText(seconds: number): string {
return seconds >= 1 ? `${formatNumber(seconds, 0)}s` : formatNumber(seconds, 1);
}