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
+1248 -28

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1963 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1964 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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;
}
+45 -17
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;
}
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);
}
+170
View File
@@ -0,0 +1,170 @@
/**
* A Reverb's node as a device (#389): every part on the plate and none over another, the numbers
* each type plays with, the knobs turning so the usual times get the turn, and the tail on the
* round screen as the impulse response rings.
*/
import { describe, expect, it } from 'vitest';
import { REVERB_ROWS } from '../../src/core/soundfx/nodes';
import { REVERB_PRESETS } from '../../src/core/soundfx/reverb';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
customFrom,
idleReverbKeys,
reflections,
REVERB_KEYS,
REVERB_KNOBS,
REVERB_LAYOUT,
REVERB_PANEL,
type ReverbKey,
type ReverbTail,
reverbValues,
SCOPE_SECONDS,
scopeRadius,
scopeSeconds,
scopeTexts,
tailAt,
tailRings,
wetShares,
} from '../../src/editor/soundfx/reverbView';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const inside = (a: Box) => a.x >= 0 && a.y >= 0 && a.x + a.w <= REVERB_PANEL.w && a.y + a.h <= REVERB_PANEL.h;
const made = (shape: { predelay: number; decay: number; damping: number }): ReverbTail => ({
kind: 'made',
...shape,
});
describe('the Reverb plate', () => {
const L = REVERB_LAYOUT;
const parts: [string, Box][] = [
['title', L.title],
['bezel', L.bezel],
['sound', L.sound],
...L.groups.map((g): [string, Box] => [g.label, g.box]),
...L.rooms.map((box, i): [string, Box] => [`room ${i}`, box]),
...REVERB_KEYS.map((key): [string, Box] => [key, knobBox(L.knobs[key])]),
];
it('has every part on it, none over another, and clear of the screws', () => {
for (const [name, box] of parts) expect(inside(box), name).toBe(true);
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++)
expect(overlap(parts[i]![1], parts[j]![1]), `${parts[i]![0]} / ${parts[j]![0]}`).toBe(false);
for (const screw of L.screws) {
const head = { x: screw.x - 6, y: screw.y - 6, w: 12, h: 12 };
for (const [name, box] of parts) expect(overlap(head, box), `screw / ${name}`).toBe(false);
}
expect(L.screen.w).toBe(L.screen.h);
});
it('turns the decay so a room sits at a third and a hall past the middle', () => {
const turn = (key: ReverbKey, value: number) => {
const row = REVERB_ROWS[key];
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...REVERB_KNOBS[key] });
};
expect(turn('decay', REVERB_PRESETS.room.decay)).toBeGreaterThan(0.3);
expect(turn('decay', REVERB_PRESETS.room.decay)).toBeLessThan(0.4);
expect(turn('decay', REVERB_PRESETS.hall.decay)).toBeGreaterThan(0.55);
expect(turn('predelay', 0.02)).toBeGreaterThan(0.15);
expect(turn('mix', 0.5)).toBe(0.5);
});
});
describe('the numbers a Reverb plays with', () => {
it('takes a room its own numbers, Custom and Sound the node’s', () => {
const typed = { decay: 7, damping: 0.9, predelay: 0.2, mix: 0.6 };
expect(reverbValues({ room: 'hall', ...typed })).toMatchObject({ ...REVERB_PRESETS.hall, mix: 0.6 });
expect(reverbValues({ room: 'custom', ...typed })).toMatchObject(typed);
expect(reverbValues({})).toMatchObject({
room: 'room',
...REVERB_PRESETS.room,
mix: REVERB_ROWS.mix.default,
});
expect(reverbValues({ room: 'custom' }).decay).toBe(REVERB_ROWS.decay.default);
expect(reverbValues({ room: 'sound', impulse: 'snd1' }).impulse).toBe('snd1');
});
it('greys what the type does not take, and a knob of it goes to Custom with the shown numbers', () => {
expect([...idleReverbKeys('hall')].sort()).toEqual(['damping', 'decay', 'impulse', 'predelay']);
expect([...idleReverbKeys('custom')]).toEqual(['impulse']);
expect([...idleReverbKeys('sound')].sort()).toEqual(['damping', 'decay']);
expect(customFrom(reverbValues({ room: 'cave' }), 'damping', 0.8)).toEqual({
room: 'custom',
decay: REVERB_PRESETS.cave.decay,
damping: 0.8,
predelay: REVERB_PRESETS.cave.predelay,
});
});
});
describe('the round screen', () => {
it('puts every Reverb on one scale of seconds, a longer tail further out', () => {
const rooms = (['room', 'plate', 'hall', 'cave'] as const).map((room) => {
const p = REVERB_PRESETS[room];
return scopeRadius(p.predelay + p.decay);
});
expect([...rooms].sort((a, b) => a - b)).toEqual(rooms);
expect(scopeRadius(SCOPE_SECONDS)).toBe(1);
expect(scopeRadius(60)).toBe(1);
expect(scopeSeconds(scopeRadius(2.5))).toBeCloseTo(2.5, 9);
});
it('waits the pre-delay, falls 60 dB in the decay and loses its highs as far as it is damped', () => {
const tail = made({ predelay: 0.1, decay: 2, damping: 1 });
expect(tailAt(tail, 0.05)).toBeNull();
expect(tailAt(tail, 0.1)).toEqual({ level: 1, bright: 1 });
expect(tailAt(tail, 1.1)!.level).toBeCloseTo(0.5, 9);
expect(tailAt(tail, 2.1)!.level).toBeCloseTo(0, 9);
expect(tailAt(tail, 2.1)!.bright).toBeCloseTo(0, 9);
expect(tailAt(made({ predelay: 0, decay: 2, damping: 0 }), 2)!.bright).toBe(1);
expect(tailAt(tail, 2.2)).toBeNull();
});
it('draws a sound’s tail as loud as its peaks against its loudest, knowing nothing of its highs', () => {
const tail: ReverbTail = {
kind: 'sound',
predelay: 0,
seconds: 1,
peaks: Float32Array.from([0.5, 0.05, 0.0005, 0]),
};
expect(tailAt(tail, 0.1)).toEqual({ level: 1, bright: null });
expect(tailAt(tail, 0.3)!.level).toBeCloseTo(2 / 3, 6);
expect(tailAt(tail, 0.6)!.level).toBeCloseTo(0, 6);
expect(tailAt(tail, 0.9)!.level).toBe(0);
});
it('rings from the pre-delay out to where it has died, the reflections in between and the same each time', () => {
const tail = made({ ...REVERB_PRESETS.hall, predelay: 0.3 });
const radius = 78;
const rings = tailRings(tail, radius);
const start = scopeRadius(0.3) * radius;
const end = scopeRadius(0.3 + REVERB_PRESETS.hall.decay) * radius;
expect(rings[0]!.r).toBeGreaterThanOrEqual(start);
expect(rings.at(-1)!.r).toBeLessThanOrEqual(end);
expect(rings.at(-1)!.r).toBeGreaterThan(end - 2);
for (let i = 1; i < rings.length; i++) expect(rings[i]!.level).toBeLessThan(rings[i - 1]!.level);
const dots = reflections(tail, radius);
expect(dots.length).toBeGreaterThan(40);
for (const dot of dots) {
expect(dot.r).toBeGreaterThanOrEqual(start);
expect(dot.r).toBeLessThanOrEqual(end);
}
expect(reflections(tail, radius)).toEqual(dots);
});
it('draws no tail at a wet of 0, and the sound faint but there at 1', () => {
expect(wetShares(0)).toEqual({ tail: 0, dry: 1 });
expect(wetShares(1)).toEqual({ tail: 1, dry: 0.2 });
});
it('says the pre-delay, where the highs end up and when the tail has died', () => {
expect(scopeTexts(made(REVERB_PRESETS.hall), '')).toEqual({
pre: 'PRE 20 ms',
highs: 'HIGHS 5.5 kHz',
ends: 'ENDS 2.52 s',
});
expect(scopeTexts(null, 'NO SOUND')).toEqual({ pre: '', highs: '', ends: 'NO SOUND' });
});
});
+73
View File
@@ -1214,6 +1214,79 @@ export async function sound(t) {
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-hw');
graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #389: a Reverb node is drawn as a device with a round screen of its tail. A knob its type does
// not take goes over to Custom with the type's numbers, as one undo step; a sound picked on
// it makes the type Sound and the screen draws that sound's tail. It is wired to nothing.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-rv', 'SoundFxReverb', 0, 400, { room: 'hall' }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const reverbPanel = () => json("__gm.reverb.panel('fx-rv')");
const reverbParams = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-rv').params`,
);
const rvShown = await reverbPanel();
const rvRect = rvShown.rect;
const rvBeside = (p) => p.y > rvRect.y && p.y < rvRect.y + rvRect.h;
const rvDial = rvShown.knobs.decay.dial;
await drag(rvDial.x, rvDial.y, rvDial.x, rvDial.y - 20);
const rvTurned = { params: await reverbParams(), panel: await reverbPanel() };
await press('z', { ctrl: true });
const rvUndone = await reverbParams();
await click(rvShown.rooms.cave.x, rvShown.rooms.cave.y);
const rvCave = { params: await reverbParams(), panel: await reverbPanel() };
await click(rvCave.panel.sound.at.x, rvCave.panel.sound.at.y);
const rvOffered = (await json('__gm.menuRows()')) ?? [];
const rvPick = rvOffered.find((row) => row);
const rvPickAt = await t.locate(rvPick, true);
await click(rvPickAt.x, rvPickAt.y);
const rvSound = {
params: await reverbParams(),
panel: await waitFor(reverbPanel, (p) => p.scope.kind === 'sound'),
};
await press('z', { ctrl: true });
const rvSoundUndone = await reverbParams();
check(
'a Reverb node shows its device between its ports; a knob of a room goes to Custom with its numbers as one undo step, a type button switches, a picked sound makes it Sound and draws that sound',
rvShown.shown &&
rvShown.size.w === 640 &&
rvShown.size.h === 280 &&
rvShown.room === 'hall' &&
['decay', 'damping', 'predelay', 'impulse'].every((key) => rvShown.idle.includes(key)) &&
Object.keys(rvShown.inputs).join() === 'in,mix' &&
Object.values(rvShown.inputs).every((p) => p.x < rvRect.x && rvBeside(p)) &&
Object.values(rvShown.outputs).every((p) => p.x > rvRect.x + rvRect.w && rvBeside(p)) &&
rvShown.scope.kind === 'made' &&
rvShown.scope.reach > 0.48 &&
rvShown.scope.reach < 0.5 &&
rvShown.scope.says.ends === 'ENDS 2.52 s' &&
rvShown.highs === 'HIGHS 5.5 kHz' &&
rvTurned.params.room === 'custom' &&
rvTurned.params.decay > 2.5 &&
rvTurned.params.damping === 0.35 &&
rvTurned.params.predelay === 0.02 &&
Object.keys(rvTurned.panel.inputs).length === 5 &&
rvTurned.panel.idle.join() === 'impulse' &&
rvTurned.panel.scope.reach > rvShown.scope.reach &&
rvUndone.room === 'hall' &&
rvUndone.decay === undefined &&
rvCave.params.room === 'cave' &&
rvCave.panel.scope.reach > 0.68 &&
Boolean(rvPick) &&
rvSound.params.room === 'sound' &&
Boolean(rvSound.params.impulse) &&
rvSound.panel.sound.text === rvPick &&
rvSound.panel.scope.kind === 'sound' &&
rvSound.panel.scope.rings > 0 &&
rvSoundUndone.room === 'cave' &&
rvSoundUndone.impulse === undefined,
JSON.stringify({ rvShown, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-rv');`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;