From e8021b1fd36cd9fd595135a1383ab40813a47734 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 16:35:59 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/soundfx/nodes.ts | 12 +- src/core/soundfx/reverb.ts | 21 +- src/editor/debug/graphHooks.ts | 44 +++ src/editor/soundfx/ReverbFace.ts | 145 ++++++++++ src/editor/soundfx/ReverbPanel.ts | 413 ++++++++++++++++++++++++++++ src/editor/soundfx/nodeFaces.ts | 12 +- src/editor/soundfx/nodeParamDrag.ts | 64 +++-- src/editor/soundfx/reverbView.ts | 322 ++++++++++++++++++++++ tests/editor/reverb-view.test.ts | 170 ++++++++++++ tools/e2e/34-sound.mjs | 73 +++++ 11 files changed, 1249 insertions(+), 29 deletions(-) create mode 100644 src/editor/soundfx/ReverbFace.ts create mode 100644 src/editor/soundfx/ReverbPanel.ts create mode 100644 src/editor/soundfx/reverbView.ts create mode 100644 tests/editor/reverb-view.test.ts diff --git a/README.md b/README.md index b116754f..30b6337e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index e12808d1..713a8499 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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'; } diff --git a/src/core/soundfx/reverb.ts b/src/core/soundfx/reverb.ts index 2e48209f..ba348f80 100644 --- a/src/core/soundfx/reverb.ts +++ b/src/core/soundfx/reverb.ts @@ -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); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 3f0aac25..5f3510aa 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/soundfx/ReverbFace.ts b/src/editor/soundfx/ReverbFace.ts new file mode 100644 index 00000000..dac9f6be --- /dev/null +++ b/src/editor/soundfx/ReverbFace.ts @@ -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(); + + 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); + } +} diff --git a/src/editor/soundfx/ReverbPanel.ts b/src/editor/soundfx/ReverbPanel.ts new file mode 100644 index 00000000..73efceb9 --- /dev/null +++ b/src/editor/soundfx/ReverbPanel.ts @@ -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; + /** 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; + private readonly captions = {} as Record; + 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 = 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 }); + } +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 9ceeadd1..39bbe040 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; } diff --git a/src/editor/soundfx/nodeParamDrag.ts b/src/editor/soundfx/nodeParamDrag.ts index fb4921f7..2a4449d7 100644 --- a/src/editor/soundfx/nodeParamDrag.ts +++ b/src/editor/soundfx/nodeParamDrag.ts @@ -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(); 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): 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 = {}; + 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, + 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 { diff --git a/src/editor/soundfx/reverbView.ts b/src/editor/soundfx/reverbView.ts new file mode 100644 index 00000000..1e45b361 --- /dev/null +++ b/src/editor/soundfx/reverbView.ts @@ -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 { + 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 { + const idle = new Set(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 { + 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 = { + 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; + 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; + 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); +} diff --git a/tests/editor/reverb-view.test.ts b/tests/editor/reverb-view.test.ts new file mode 100644 index 00000000..d696e996 --- /dev/null +++ b/tests/editor/reverb-view.test.ts @@ -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' }); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 24bca172..1d229f11 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;