Show a Reverb node as a device with a round screen of its tail (#389)
The Compressor, the Gain, the Chip and the Envelope are devices in the graph. The Reverb was still a list of rows, and how long and how dark its tail rings was left to the numbers. - The node is a plate of 640x280 in pale lavender. Its inputs are on the left, its output on the right. - A round screen draws the tail as rings out from the sound. A ring's radius is its time, as the root of its seconds, on one scale up to 10.5 s for every Reverb. So a Room is a small disc and a Cave fills two thirds. - A ring is as bright as the tail is loud there, evenly in dB. Its colour is how much of the highs is left, from ice to violet, with the cut-off of the impulse response (reverbCutoff, now shared). - The pre-delay is a dark gap round the dot, a dashed ring marks -60 dB. Seeded dots stand for the reflections. Wet fades the tail against the dot. - A type of Sound draws the peaks of its sound from the library, each against its loudest. It says LOADING until they are there. - A button for each type, a list of the sounds and four chrome knobs: Decay, Damping, Pre-delay and Wet. - A knob the type does not take is greyed but turns. It goes over to Custom with the type's numbers, as one undo step. Picking a sound makes the type Sound, also one step. - NodeParamDrag can write several settings as one drag for that. - Test hook __gm.reverb.panel(nodeId), for the sound FX and the instrument graph. Tests: - Unit reverb-view: 10 new tests for the plate, the knob turn, the numbers per type, the scale, the tail and the texts. A screen that ignores damping turns the highs test red. - e2e 34 (atx): 1 new check (device between its ports, decay knob of a Hall goes to Custom as one undo step, Cave button, a sound from the list makes it Sound and draws that sound). - Probe pictures on stein2: Room, Hall, Cave, Plate, Custom with damping 1 and pre-delay 0.3 s, Sound. - Gate (atx): CHECK EXIT 0, 287 files, 4393 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c75e72d4ee
commit
e8021b1fd3
11 files changed
+1249
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user