From c75e72d4ee78ae4965c8a1e761d8f3d69bd6cb1e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 16:06:15 +0200 Subject: [PATCH] Show an Envelope node as a device with its ADSR curve (#388) The Compressor, the Gain and the Chip are devices in the graph. The Envelope was still a list of rows, and its shape was left to the imagination of whoever read the numbers. - The node is a plate of 520x280 in pale sea green. Its inputs are on the left, out and value on the right. - A dark screen draws the curve over a note in amber. A letter sits above each stage, its time below it, and the sustain in percent above its line. - The time on the screen is squeezed. Each stage is as wide as the root of its seconds, at least 3 %, and the hold has a fixed fifth. So 5 ms of attack still shows beside a release of 3 s. - The falls use SETTLE, as the player does. With sustain 0 there is no hold and no release, and the screen says NOTE ENDS. - Attack, Decay, Sustain and Release are chrome knobs. The times use taper 4, so 5 ms sits an eighth of the way and 1 s not quite half. - From and To have no range a knob could turn through. They are NumberFields on the plate, typed in or scrubbed. - The decay at sustain 1 and From/To while nothing takes value are faded. While a link takes value, the bezel says what it moves between, as VALUE 400 TO 3500. The UI font has no arrow. - Instrument presets give an Envelope 3 columns of 240. - Test hook __gm.envelope.panel(nodeId). Tests: - Unit envelope-view: 6 new tests for the plate, the knob turn, the idle settings and the curve. A linear decay turns two of them red. - e2e 34 (atx): 1 new check on the Laser preset (device between its ports, curve ends with the decay, decay knob and scrubbed To field each one undo step). - Probe pictures on stein2: Synth Brass preset, pad, pluck, organ and an Envelope whose value moves a Chip. - Gate (atx): CHECK EXIT 0, 286 files, 4383 tests. The first run was red on the font test (#214) for an arrow in the bezel text. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/instrument/chipPresets.ts | 8 +- src/editor/debug/graphHooks.ts | 49 ++++ src/editor/soundfx/EnvelopeFace.ts | 73 ++++++ src/editor/soundfx/EnvelopePanel.ts | 350 ++++++++++++++++++++++++++++ src/editor/soundfx/envelopeView.ts | 232 ++++++++++++++++++ src/editor/soundfx/nodeFaces.ts | 6 +- tests/editor/envelope-view.test.ts | 128 ++++++++++ tools/e2e/34-sound.mjs | 44 ++++ 9 files changed, 887 insertions(+), 5 deletions(-) create mode 100644 src/editor/soundfx/EnvelopeFace.ts create mode 100644 src/editor/soundfx/EnvelopePanel.ts create mode 100644 src/editor/soundfx/envelopeView.ts create mode 100644 tests/editor/envelope-view.test.ts diff --git a/README.md b/README.md index e9273ab0..b116754f 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, 1962 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1963 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/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 4724c3e7..9a2ff183 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -395,9 +395,13 @@ const COLUMN = 240; const ROW = 340; /** * Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide, - * a Chip's device (#386) wider. + * a Chip's device (#386) wider, an Envelope's (#388) a little less. */ -const COLUMNS_OF: Readonly> = { [SoundFxFilter.type]: 4, [INSTRUMENT_CHIP]: 5 }; +const COLUMNS_OF: Readonly> = { + [SoundFxFilter.type]: 4, + [INSTRUMENT_CHIP]: 5, + [INSTRUMENT_ENVELOPE]: 3, +}; /** The column right of a node, past all it takes. */ const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 930dfb4b..3f0aac25 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -4,6 +4,7 @@ import { soundLibrary } from '../../viewport/SoundLibrary'; import type { Widget } from '../../ui'; import { currentNodePalette } from '../graph/NodePalette'; import { ChipPanel } from '../soundfx/ChipPanel'; +import { EnvelopePanel } from '../soundfx/EnvelopePanel'; import { CompressorPanel } from '../soundfx/CompressorPanel'; import { GainPanel } from '../soundfx/GainPanel'; import { CurvePanel } from '../soundfx/CurvePanel'; @@ -317,6 +318,54 @@ export function graphHooks({ state, editor }: HookContext) { }; }, }, + /** + * The panel an Envelope node shows in the instrument graph (#388): whether it is shown and + * where, its knobs and fields, which settings are idle, the curve's stages, what the screen says + * and the node's ports. + */ + envelope: { + panel: (nodeId: string) => { + const canvas = editor.instrumentEditor.canvas; + const panel = canvas.faceOf(nodeId)?.widget; + if (!(panel instanceof EnvelopePanel)) return null; + const node = canvas.nodeScreen(nodeId); + const ports = (keys: string[], isOutput: boolean) => + Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)])); + const trace = panel.screen.trace; + 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() }, + ]), + ), + fields: Object.fromEntries( + Object.entries(panel.fields).map(([key, field]) => [ + key, + { text: field.value, enabled: field.enabled, at: screenCentre(field) }, + ]), + ), + idle: [...panel.idle], + /** The texts the screen shows, by name. */ + screen: Object.fromEntries(panel.screen.says), + /** What the value moves between, above the screen; empty while nothing takes it. */ + range: panel.range.text, + trace: trace && { + stages: trace.stages.map((s) => ({ key: s.key, from: s.from, to: s.to, seconds: s.seconds })), + sustain: trace.sustain, + ends: trace.ends, + /** The level where the curve stops. */ + last: trace.points.at(-1)?.y ?? null, + }, + }; + }, + }, instrument: { current: () => editor.instrumentEditor.currentAssetId, info: () => editor.instrumentEditor.shownInfo, diff --git a/src/editor/soundfx/EnvelopeFace.ts b/src/editor/soundfx/EnvelopeFace.ts new file mode 100644 index 00000000..e1b3efd2 --- /dev/null +++ b/src/editor/soundfx/EnvelopeFace.ts @@ -0,0 +1,73 @@ +import type { GraphNode } from '../../core/graph'; +import { InstrumentEnvelope } from '../../core/instrument/nodes'; +import { findGraphAsset } from '../../core/model/Project'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { compressorWired } from './CompressorEditor'; +import { type EnvelopeHost, EnvelopePanel } from './EnvelopePanel'; +import { type EnvelopeKey, envelopeValues, idleEnvelopeKeys } from './envelopeView'; +import { NodeParamDrag } from './nodeParamDrag'; + +/** The output an Envelope moves a number by. */ +const VALUE = 'value'; + +/** + * An Envelope node drawn as its device (#388): its curve drawn from its settings, every one of them + * worked right in the graph. It listens to nothing, so it has nothing to move between changes. + */ +export class EnvelopeFace implements NodeFace, EnvelopeHost { + readonly widget: EnvelopePanel; + private readonly drag: NodeParamDrag; + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + ) { + this.widget = new EnvelopePanel(this); + this.drag = new NodeParamDrag(state, 'envelope-face'); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === InstrumentEnvelope.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 = envelopeValues(node.params); + const valueTaken = Boolean( + findGraphAsset(this.state.project, this.assetId)?.graph.edges.some( + (e) => e.from.node === this.nodeId && e.from.port === VALUE, + ), + ); + this.widget.sync({ + values, + wired: this.wired, + valueTaken, + idle: idleEnvelopeKeys(values, this.wired, valueTaken), + }); + } + + tick(): boolean { + return false; + } + + hide(): void {} + + preview(key: EnvelopeKey, value: number): void { + const node = this.node(); + if (node) this.drag.preview(node, key, value); + } + + commit(key: EnvelopeKey, value: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, key, value); + } +} diff --git a/src/editor/soundfx/EnvelopePanel.ts b/src/editor/soundfx/EnvelopePanel.ts new file mode 100644 index 00000000..8dab3c6e --- /dev/null +++ b/src/editor/soundfx/EnvelopePanel.ts @@ -0,0 +1,350 @@ +/** + * The Envelope as a device (#388), what its node shows in the instrument graph: the Compressor's + * plate (#379) in pale sea green, a dark screen that draws the curve over a note in amber, a chrome + * knob for each stage and two typed numbers for where the value goes from and to. + */ +import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { ENVELOPE_ROWS } from '../../core/instrument/nodes'; +import { crispText, Knob, NumberField, textStyle, Widget } from '../../ui'; +import { formatNumber } from '../../ui/math'; +import { apartLabels } from './eqView'; +import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; +import { + ENVELOPE_FIELD_KEYS, + ENVELOPE_KNOB_KEYS, + ENVELOPE_KNOBS, + ENVELOPE_LAYOUT, + ENVELOPE_PANEL, + type EnvelopeKey, + type EnvelopeTrace, + envelopeTrace, + type EnvelopeValues, + STAGE_LETTERS, + stageText, + sustainText, +} from './envelopeView'; +import { type Box, KNOB_CAPTION } from './hardwareView'; + +/** What the panel asks of the node it sits in. */ +export interface EnvelopeHost { + preview(key: EnvelopeKey, value: number): void; + commit(key: EnvelopeKey, value: number): void; +} + +/** What the panel shows of a node. */ +export interface EnvelopeShown { + values: EnvelopeValues; + wired: (key: EnvelopeKey) => boolean; + /** Whether a link takes the value: the screen then says what it moves between. */ + valueTaken: boolean; + /** The settings that do nothing as things stand. */ + idle: ReadonlySet; +} + +/** Pale anodised sea green. */ +const SEA: PlateLook = { + stops: [ + [0, 0xcdeae2], + [0.5, 0xb2dccf], + [1, 0x91c4b5], + ], + rim: 0x5f9384, + grain: 0x3d6e60, +}; +const BEZEL = 0x2f3a3a; +const BEZEL_TEXT = 0xb6d4cc; +/** An oscilloscope's dark glass and its amber trace. */ +const SCOPE = { glass: 0x101a1b, grid: 0x223536, trace: 0xffb43c, dim: 0xa0742c }; +const IDLE_ALPHA = 0.4; +/** The rows of text above and below the curve. */ +const TEXT_ROW = 14; + +function scopeStyle(size: number, fill: number = SCOPE.trace): TextStyle { + const style = textStyle('small').clone(); + style.fill = fill; + style.fontFamily = 'monospace'; + style.fontSize = size; + style.fontWeight = 'bold'; + return style; +} + +function dashed(g: Graphics, x0: number, x1: number, y: number, dash = 3): void { + for (let x = x0; x < x1; x += dash * 2) g.moveTo(x, y).lineTo(Math.min(x1, x + dash), y); +} + +/** + * The screen: the curve over a note, a letter above each stage and its time below it, and the + * sustain above its line. + */ +class EnvelopeScreen extends Widget { + private readonly g = new Graphics(); + private readonly texts = new Map(); + trace: EnvelopeTrace | null = null; + /** What the texts say now, by name (checks). */ + readonly says = new Map(); + + constructor() { + super(); + this.addChild(this.g); + } + + show(trace: EnvelopeTrace): void { + this.trace = trace; + this.invalidate(); + } + + /** Where the curve is drawn: between the rows of text, a little in from the sides. */ + get plot(): Box { + return { x: 8, y: TEXT_ROW + 4, w: this.w - 16, h: this.h - 2 * TEXT_ROW - 8 }; + } + + /** A text of the screen by name, made the first time it is asked for. */ + private text(name: string, size: number, fill?: number): Text { + let t = this.texts.get(name); + if (!t) { + t = crispText({ text: '', style: scopeStyle(size, fill) }); + t.eventMode = 'none'; + this.texts.set(name, t); + this.addChild(t); + } + return t; + } + + /** Writes and places each text in the order given, leaving out one that would run into the last. */ + private row( + items: { name: string; says: string; x: number; y: number; anchor: number; dim?: boolean }[], + ): void { + const made = items.map((item) => { + const t = this.text(item.name, 9, item.dim ? SCOPE.dim : SCOPE.trace); + t.text = item.says; + t.anchor.set(item.anchor, 0); + t.position.set(Math.round(item.x), Math.round(item.y)); + return t; + }); + const shown = apartLabels( + made.map((t, i) => { + const left = items[i]!.x - t.width * items[i]!.anchor; + return { from: left, to: left + t.width }; + }), + 4, + ); + made.forEach((t, i) => { + t.visible = shown[i]!; + if (t.visible) this.says.set(items[i]!.name, items[i]!.says); + }); + } + + override redraw(): void { + const g = this.g.clear(); + g.roundRect(0, 0, this.w, this.h, 3).fill(SCOPE.glass); + for (const t of this.texts.values()) t.visible = false; + this.says.clear(); + const trace = this.trace; + if (!trace) return; + const p = this.plot; + const x = (share: number) => p.x + share * p.w; + const y = (level: number) => p.y + (1 - level) * p.h; + for (let i = 0; i <= 4; i++) g.moveTo(p.x, p.y + (p.h * i) / 4).lineTo(p.x + p.w, p.y + (p.h * i) / 4); + for (const stage of trace.stages.slice(1)) g.moveTo(x(stage.from), p.y).lineTo(x(stage.from), p.y + p.h); + g.stroke({ color: SCOPE.grid, width: 1 }); + const hold = trace.stages.find((stage) => stage.key === 'hold'); + if (hold) { + dashed(g, p.x, x(hold.from), y(trace.sustain)); + g.stroke({ color: SCOPE.dim, width: 1 }); + } + const line = trace.points.map((q) => ({ x: x(q.x), y: y(q.y) })); + line.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y))); + g.lineTo(line[line.length - 1]!.x, y(0)).lineTo(line[0]!.x, y(0)); + g.fill({ color: SCOPE.trace, alpha: 0.14 }); + line.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y))); + g.stroke({ color: SCOPE.trace, width: 2, join: 'round' }); + + const centre = (stage: { from: number; to: number }) => x((stage.from + stage.to) / 2); + this.row([ + ...(trace.ends ? [{ name: 'ends', says: 'NOTE ENDS', x: p.x + p.w, y: 2, anchor: 1, dim: true }] : []), + ...trace.stages.map((stage) => ({ + name: `letter-${stage.key}`, + says: STAGE_LETTERS[stage.key], + x: centre(stage), + y: 2, + anchor: 0.5, + })), + ]); + this.row( + trace.stages.map((stage) => ({ + name: `time-${stage.key}`, + says: stageText(stage), + x: centre(stage), + y: this.h - TEXT_ROW, + anchor: 0.5, + dim: stage.key === 'hold', + })), + ); + if (hold) + this.row([ + { + name: 'sustain', + says: sustainText(trace.sustain), + x: centre(hold), + y: Math.max(p.y, y(trace.sustain) - 13), + anchor: 0.5, + }, + ]); + } +} + +export class EnvelopePanel extends Widget { + private readonly plate = new Graphics(); + private readonly bezel = new Graphics(); + private readonly rules = new Graphics(); + readonly screen = new EnvelopeScreen(); + readonly knobs = {} as Record<(typeof ENVELOPE_KNOB_KEYS)[number], Knob>; + readonly fields = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], NumberField>; + private readonly captions = {} as Record; + private readonly groupNames: { ink: Text; edge: Text }[]; + private readonly title: { ink: Text; edge: Text }; + private readonly screenName: Text; + /** What the value moves between, while a link takes it (checks). */ + readonly range: Text; + /** The settings shown idle now (checks). */ + idle: ReadonlySet = new Set(); + + constructor(host: EnvelopeHost) { + super(); + this.minWidth = ENVELOPE_PANEL.w; + this.minHeight = ENVELOPE_PANEL.h; + this.addChild(this.plate, this.rules, this.bezel, this.screen); + this.title = engrave(this, 'ENVELOPE', 14); + const nameStyle = textStyle('small').clone(); + nameStyle.fill = BEZEL_TEXT; + nameStyle.fontSize = 9; + nameStyle.fontWeight = 'bold'; + nameStyle.letterSpacing = 1; + this.screenName = crispText({ text: 'LEVEL OVER A NOTE', style: nameStyle }); + this.screenName.eventMode = 'none'; + this.range = crispText({ text: '', style: nameStyle }); + this.range.anchor.set(1, 0); + this.range.eventMode = 'none'; + this.addChild(this.screenName, this.range); + this.groupNames = ENVELOPE_LAYOUT.groups.map((group) => engrave(this, group.label, 9)); + for (const key of ENVELOPE_KNOB_KEYS) { + const row = ENVELOPE_ROWS[key]; + const look = ENVELOPE_KNOBS[key]; + const knob = new Knob('', row.default, { + min: row.min ?? 0, + max: row.max ?? 0, + step: row.step ?? 1, + taper: look.taper, + default: row.default, + decimals: look.decimals, + size: ENVELOPE_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); + } + for (const key of ENVELOPE_FIELD_KEYS) { + const row = ENVELOPE_ROWS[key]; + const field = new NumberField(row.default, { + width: ENVELOPE_LAYOUT.fields[key].w, + step: row.step, + decimals: 2, + onInput: (value) => host.preview(key, value), + onChange: (value) => host.commit(key, value), + }); + this.fields[key] = field; + this.captions[key] = engrave(this, key.toUpperCase(), 9); + this.addChild(field); + } + } + + override measure(): { w: number; h: number } { + return { ...ENVELOPE_PANEL }; + } + + sync(shown: EnvelopeShown): void { + for (const key of ENVELOPE_KNOB_KEYS) { + const knob = this.knobs[key]; + knob.setValue(shown.values[key]); + knob.setEnabled(!shown.wired(key)); + knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; + } + for (const key of ENVELOPE_FIELD_KEYS) { + const field = this.fields[key]; + field.setNumber(shown.values[key]); + field.setEnabled(!shown.wired(key)); + field.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; + } + for (const key of [...ENVELOPE_KNOB_KEYS, ...ENVELOPE_FIELD_KEYS]) + this.captions[key].ink.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; + this.idle = shown.idle; + this.screen.show(envelopeTrace(shown.values)); + // Above the screen, clear of the curve: what the value moves between while a link takes it. + this.range.text = shown.valueTaken + ? `VALUE ${formatNumber(shown.values.from, 2)} TO ${formatNumber(shown.values.to, 2)}` + : ''; + } + + override layout(): void { + const L = ENVELOPE_LAYOUT; + this.screen.position.set(L.screen.x, L.screen.y); + this.screen.resize(L.screen.w, L.screen.h); + this.screenName.position.set(L.screen.x, L.bezel.y + 3); + this.range.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); + for (const key of ENVELOPE_KNOB_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); + } + for (const key of ENVELOPE_FIELD_KEYS) { + const box = L.fields[key]; + const field = this.fields[key]; + const size = field.measure(); + field.position.set(Math.round(box.x), Math.round(box.y + (box.h - size.h) / 2)); + field.resize(box.w, size.h); + placeEngraving( + this.captions[key], + box.x + box.w / 2, + L.knobs.attack.y - L.knobs.attack.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 = ENVELOPE_LAYOUT; + drawPlate(this.plate.clear(), ENVELOPE_PANEL, SEA, 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: 0x1c2323, 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/envelopeView.ts b/src/editor/soundfx/envelopeView.ts new file mode 100644 index 00000000..3dbb75b9 --- /dev/null +++ b/src/editor/soundfx/envelopeView.ts @@ -0,0 +1,232 @@ +/** + * What an Envelope's node draws (#388), as numbers: where the parts of its device sit, how its + * knobs turn, and its curve over a note as the screen shows it. Kept apart from the drawing so it is + * checked without a browser. + */ +import { releaseTime, SETTLE } from '../../core/instrument/envelope'; +import { ENVELOPE_ROWS } from '../../core/instrument/nodes'; +import type { PropertyBag } from '../../core/model/Properties'; +import { formatNumber } from '../../ui/math'; +import { secondsText } from './chipView'; +import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; + +export type EnvelopeKey = keyof typeof ENVELOPE_ROWS; +export type EnvelopeValues = Record; + +export const ENVELOPE_KEYS = Object.keys(ENVELOPE_ROWS) as EnvelopeKey[]; +/** The four stages turn a knob each; the two ends of the value have no range, so they are typed. */ +export const ENVELOPE_KNOB_KEYS = ['attack', 'decay', 'sustain', 'release'] as const; +export const ENVELOPE_FIELD_KEYS = ['from', 'to'] as const; +export type EnvelopeKnobKey = (typeof ENVELOPE_KNOB_KEYS)[number]; +export type EnvelopeFieldKey = (typeof ENVELOPE_FIELD_KEYS)[number]; + +/** The numbers of an Envelope node, each its row's default where none is typed in. */ +export function envelopeValues(params: PropertyBag): EnvelopeValues { + const values = {} as EnvelopeValues; + for (const key of ENVELOPE_KEYS) { + const value = params[key]; + values[key] = typeof value === 'number' && Number.isFinite(value) ? value : ENVELOPE_ROWS[key].default; + } + return values; +} + +export interface EnvelopeKnob { + label: string; + taper: number; + decimals: number; + format?: (value: number) => string; +} + +/** + * How each knob turns: a time of 0 to 30 s gives most of the turn to its first second – 5 ms sits + * an eighth of the way, 1 s not quite half – and the sustain turns evenly. + */ +export const ENVELOPE_KNOBS: Record = { + attack: { label: 'Attack', taper: 4, decimals: 3, format: secondsText }, + decay: { label: 'Decay', taper: 4, decimals: 3, format: secondsText }, + sustain: { label: 'Sustain', taper: 1, decimals: 2 }, + release: { label: 'Release', taper: 4, decimals: 3, format: secondsText }, +}; + +/** + * The settings that do nothing as things stand, greyed as the Chip's are: the decay with the + * sustain at full, and the two ends of the value while nothing takes it. + */ +export function idleEnvelopeKeys( + values: EnvelopeValues, + fed: (key: EnvelopeKey) => boolean, + valueTaken: boolean, +): Set { + const idle = new Set(); + if (!fed('decay') && !fed('sustain') && values.sustain >= 1) idle.add('decay'); + if (!valueTaken) for (const key of ENVELOPE_FIELD_KEYS) if (!fed(key)) idle.add(key); + return idle; +} + +// --- The plate. --- + +/** The plate: as the Compressor's (#379), the panel never takes another size. */ +export const ENVELOPE_PANEL = { w: 520, h: 280 }; + +const KNOB_SLOT = 64; +const FIELD_SLOT = 84; +const GROUP_GAP = 22; +/** The height of a group's name and its rule. */ +const GROUP_LINE = 16; +export const ENVELOPE_FIELD = { w: 72, h: 24 }; + +export interface EnvelopeLayout { + title: Box; + /** The dark frame round the screen, and the screen in it. */ + bezel: Box; + screen: Box; + groups: { label: string; box: Box }[]; + knobs: Record; + /** A typed number by the middle of its box, its name engraved above as a knob's is. */ + fields: Record; + screws: { x: number; y: number }[]; +} + +function envelopeLayout(): EnvelopeLayout { + const { w } = ENVELOPE_PANEL; + const bezel = { x: 24, y: 44, w: w - 48, h: 126 }; + const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 }; + const stages = ENVELOPE_KNOB_KEYS.length * KNOB_SLOT; + const value = ENVELOPE_FIELD_KEYS.length * FIELD_SLOT; + const groupTop = bezel.y + bezel.h + 6; + const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + let x = (w - stages - GROUP_GAP - value) / 2; + const groups = [{ label: 'ADSR', box: { x, y: groupTop, w: stages, h: GROUP_LINE } }]; + const knobs = {} as Record; + for (const key of ENVELOPE_KNOB_KEYS) { + knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL }; + x += KNOB_SLOT; + } + x += GROUP_GAP; + groups.push({ label: 'VALUE', box: { x, y: groupTop, w: value, h: GROUP_LINE } }); + const fields = {} as Record; + for (const key of ENVELOPE_FIELD_KEYS) { + fields[key] = { + x: x + (FIELD_SLOT - ENVELOPE_FIELD.w) / 2, + y: dialY - ENVELOPE_FIELD.h / 2, + ...ENVELOPE_FIELD, + }; + x += FIELD_SLOT; + } + return { + title: { x: 34, y: 14, w: 120, h: 24 }, + bezel, + screen, + groups, + knobs, + fields, + screws: screwsOf(ENVELOPE_PANEL), + }; +} + +export const ENVELOPE_LAYOUT: EnvelopeLayout = envelopeLayout(); + +// --- The screen: the curve over a note. --- + +export type StageKey = 'attack' | 'decay' | 'hold' | 'release'; + +export interface Stage { + key: StageKey; + /** Where it begins and ends across the screen, 0 to 1. */ + from: number; + to: number; + /** How long it takes; null for the hold, which lasts as long as the note is held. */ + seconds: number | null; +} + +export interface EnvelopeTrace { + /** The level from 0 to 1 across the screen, `x` from 0 to 1. */ + points: { x: number; y: number }[]; + stages: Stage[]; + sustain: number; + /** With the sustain at 0 the note ends with its decay, however long it is held. */ + ends: boolean; +} + +/** The share of the screen the hold takes, whatever the times are. */ +export const HOLD_SHARE = 0.2; +/** The least share a stage of a time above 0 takes, so 1 ms of attack is still a slope. */ +const LEAST_SHARE = 0.03; +/** Points a falling stage is drawn with. */ +const FALL_POINTS = 48; + +/** + * The curve over a note (#388): a straight line up in the attack, down towards the sustain in the + * decay, flat while the note is held and down to silence in the release – each fall by `SETTLE` + * time constants in its time, as the Envelope plays it. The time is squeezed: each stage is as + * wide as the root of its seconds, so 5 ms of attack shows beside a release of 3 s; the hold has a + * fixed share. With the sustain at 0 there is no hold and no release to draw. + */ +export function envelopeTrace(v: EnvelopeValues): EnvelopeTrace { + const sustain = Math.min(1, Math.max(0, v.sustain)); + const ends = sustain <= 0; + const attack = Math.max(0, v.attack); + const decay = Math.max(0, v.decay); + const release = releaseTime({ id: '', attack, decay, sustain, release: v.release }); + const timed: { key: StageKey; seconds: number }[] = [ + { key: 'attack', seconds: attack }, + { key: 'decay', seconds: decay }, + ...(ends ? [] : [{ key: 'release' as const, seconds: release }]), + ]; + const weights = timed.map((stage) => Math.sqrt(stage.seconds)); + const total = weights.reduce((sum, w) => sum + w, 0); + const room = ends ? 1 : 1 - HOLD_SHARE; + // Every stage of a time above 0 gets its least share first, the rest of the room by weight. + const floored = timed.filter((stage) => stage.seconds > 0).length * LEAST_SHARE; + const shares = weights.map((w, i) => + timed[i]!.seconds > 0 ? LEAST_SHARE + (total > 0 ? ((room - floored) * w) / total : 0) : 0, + ); + if (total === 0 && !ends) shares[timed.length - 1] = room; + const stages: Stage[] = []; + let x = 0; + const push = (key: StageKey, share: number, seconds: number | null) => { + stages.push({ key, from: x, to: x + share, seconds }); + x += share; + }; + push('attack', shares[0]!, attack); + push('decay', shares[1]!, decay); + if (!ends) { + push('hold', HOLD_SHARE, null); + push('release', shares[2]!, release); + } + // Rounding leaves the last stage a hair short or long: it ends at the right edge. + stages[stages.length - 1]!.to = 1; + + const [up, down, held, gone] = stages; + const points: { x: number; y: number }[] = [ + { x: 0, y: 0 }, + { x: up!.to, y: 1 }, + ]; + const fall = (stage: Stage, top: number, bottom: number) => { + for (let i = 1; i <= FALL_POINTS; i++) { + const u = i / FALL_POINTS; + const y = bottom + (top - bottom) * Math.exp(-u * SETTLE); + points.push({ x: stage.from + u * (stage.to - stage.from), y }); + } + }; + if (decay > 0) fall(down!, 1, sustain); + else points.push({ x: down!.from, y: sustain }, { x: down!.to, y: sustain }); + if (held && gone) { + points.push({ x: held.to, y: sustain }); + fall(gone, sustain, 0); + } + return { points, stages, sustain, ends }; +} + +/** What the screen writes under a stage. */ +export function stageText(stage: Stage): string { + return stage.seconds === null ? 'HOLD' : secondsText(stage.seconds); +} + +/** The letter above a stage. */ +export const STAGE_LETTERS: Record = { attack: 'A', decay: 'D', hold: 'S', release: 'R' }; + +/** The sustain as the screen writes it beside the hold: `60%`. */ +export function sustainText(sustain: number): string { + return `${formatNumber(Math.round(sustain * 100), 0)}%`; +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 4e9c756e..9ceeadd1 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -1,18 +1,20 @@ import type { GraphNode } from '../../core/graph'; -import { InstrumentChip } from '../../core/instrument/nodes'; +import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes'; import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; import { ChipFace } from './ChipFace'; import { CompressorFace } from './CompressorFace'; import { CurveFace } from './CurveFace'; +import { EnvelopeFace } from './EnvelopeFace'; import { EqPanel } from './EqPanel'; import { FilterPanel } from './FilterPanel'; import { GainFace } from './GainFace'; -/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385, #386), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388), 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); if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id); if (node.type === SoundFxParametricEq.type) return new CurveFace(new EqPanel(state, true), assetId, node.id); diff --git a/tests/editor/envelope-view.test.ts b/tests/editor/envelope-view.test.ts new file mode 100644 index 00000000..d164f7ce --- /dev/null +++ b/tests/editor/envelope-view.test.ts @@ -0,0 +1,128 @@ +/** + * An Envelope's node as a device (#388): every part on the plate and none over another, the knobs + * turning so the usual times get the turn, and the curve over a note as the Envelope plays it. + */ +import { describe, expect, it } from 'vitest'; +import { envelopeLevel } from '../../src/core/instrument/envelope'; +import { ENVELOPE_ROWS } from '../../src/core/instrument/nodes'; +import { + ENVELOPE_FIELD_KEYS, + ENVELOPE_KNOB_KEYS, + ENVELOPE_KNOBS, + ENVELOPE_LAYOUT, + ENVELOPE_PANEL, + envelopeTrace, + envelopeValues, + type EnvelopeValues, + HOLD_SHARE, + idleEnvelopeKeys, + sustainText, +} from '../../src/editor/soundfx/envelopeView'; +import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; +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 <= ENVELOPE_PANEL.w && a.y + a.h <= ENVELOPE_PANEL.h; + +const values = (changes: Partial = {}): EnvelopeValues => ({ + ...envelopeValues({}), + ...changes, +}); + +describe('the Envelope plate', () => { + const L = ENVELOPE_LAYOUT; + const parts: [string, Box][] = [ + ['title', L.title], + ['bezel', L.bezel], + ...L.groups.map((g): [string, Box] => [g.label, g.box]), + ...ENVELOPE_KNOB_KEYS.map((key): [string, Box] => [key, knobBox(L.knobs[key])]), + ...ENVELOPE_FIELD_KEYS.map((key): [string, Box] => [key, L.fields[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([...ENVELOPE_KNOB_KEYS, ...ENVELOPE_FIELD_KEYS].sort()).toEqual(Object.keys(ENVELOPE_ROWS).sort()); + }); + + it('turns a time so the first second gets most of the turn', () => { + const turn = (key: (typeof ENVELOPE_KNOB_KEYS)[number], value: number) => { + const row = ENVELOPE_ROWS[key]; + return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...ENVELOPE_KNOBS[key] }); + }; + expect(turn('attack', 0.005)).toBeGreaterThan(0.1); + expect(turn('release', 1)).toBeGreaterThan(0.4); + expect(turn('release', 1)).toBeLessThan(0.5); + expect(turn('sustain', 0.5)).toBe(0.5); + }); + + it('greys the decay at full sustain and the ends of the value while nothing takes it', () => { + const open = () => false; + expect([...idleEnvelopeKeys(values({ sustain: 1 }), open, false)].sort()).toEqual([ + 'decay', + 'from', + 'to', + ]); + expect([...idleEnvelopeKeys(values({ sustain: 0.5 }), open, true)]).toEqual([]); + // A link to the sustain may lower it while it plays: the decay is no longer idle then. + expect([...idleEnvelopeKeys(values({ sustain: 1 }), (key) => key === 'sustain', true)]).toEqual([]); + }); +}); + +describe('the curve over a note', () => { + it('rises in the attack, falls to the sustain as the Envelope does, holds and fades out', () => { + const v = values({ attack: 0.1, decay: 0.4, sustain: 0.5, release: 0.3 }); + const trace = envelopeTrace(v); + expect(trace.stages.map((s) => s.key)).toEqual(['attack', 'decay', 'hold', 'release']); + expect(trace.stages[0]!.from).toBe(0); + expect(trace.stages.at(-1)!.to).toBe(1); + for (let i = 1; i < trace.stages.length; i++) + expect(trace.stages[i]!.from).toBeCloseTo(trace.stages[i - 1]!.to); + const hold = trace.stages[2]!; + expect(hold.to - hold.from).toBeCloseTo(HOLD_SHARE); + // At the top after the attack. + expect(trace.points.find((p) => p.x === trace.stages[0]!.to)?.y).toBe(1); + // Halfway through the decay, the level the player has there. + const decay = trace.stages[1]!; + const mid = (decay.from + decay.to) / 2; + const near = trace.points.reduce((a, b) => (Math.abs(b.x - mid) < Math.abs(a.x - mid) ? b : a)); + const u = (near.x - decay.from) / (decay.to - decay.from); + expect(near.y).toBeCloseTo(envelopeLevel({ id: '', ...v }, v.attack + u * v.decay), 6); + // Held at the sustain, and all but silent at the end. + expect(trace.points.find((p) => p.x === hold.to)?.y).toBe(0.5); + expect(trace.points.at(-1)!.y).toBeLessThan(0.001); + }); + + it('gives a short attack a slope beside a long release, and a longer time more room', () => { + const trace = envelopeTrace(values({ attack: 0.005, decay: 0.1, sustain: 0.6, release: 3 })); + const width = (key: string) => { + const s = trace.stages.find((stage) => stage.key === key)!; + return s.to - s.from; + }; + expect(width('attack')).toBeGreaterThan(0.03); + expect(width('decay')).toBeGreaterThan(width('attack')); + expect(width('release')).toBeGreaterThan(width('decay')); + }); + + it('ends with the decay when the sustain is 0, and jumps where a time is 0', () => { + const plucked = envelopeTrace(values({ attack: 0.001, decay: 0.6, sustain: 0, release: 0.1 })); + expect(plucked.ends).toBe(true); + expect(plucked.stages.map((s) => s.key)).toEqual(['attack', 'decay']); + expect(plucked.points.at(-1)!.x).toBe(1); + expect(plucked.points.at(-1)!.y).toBeCloseTo(0.001, 9); + const struck = envelopeTrace(values({ attack: 0, decay: 0, sustain: 0.7, release: 0.2 })); + expect(struck.points.slice(0, 2)).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + ]); + expect(struck.points[2]).toEqual({ x: 0, y: 0.7 }); + expect(sustainText(0.7)).toBe('70%'); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 48a84b02..24bca172 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -2345,6 +2345,50 @@ export async function sound(t) { }), ); + // #388: the Laser's Envelope is drawn as a device between its ports, its curve on a screen. Its + // sustain is 0, so the curve ends with the decay; nothing takes its value, so "from" and + // "to" are grey. A knob and a scrubbed field each change it as one undo step. + const envelopeId = ( + await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentEnvelope')`, + ) + ).id; + const envelopePanel = () => json(`__gm.envelope.panel('${envelopeId}')`); + const envShown = await envelopePanel(); + const envRect = envShown.rect; + const envBeside = (p) => p.y > envRect.y && p.y < envRect.y + envRect.h; + const decayDial = envShown.knobs.decay.dial; + await drag(decayDial.x, decayDial.y, decayDial.x, decayDial.y - 20); + const envTurned = { params: (await insGraph()).envelope, panel: await envelopePanel() }; + const toField = envShown.fields.to.at; + await drag(toField.x, toField.y, toField.x + 30, toField.y); + const envScrubbed = { params: (await insGraph()).envelope, panel: await envelopePanel() }; + for (let i = 0; i < 2; i++) await press('z', { ctrl: true }); + const envUndone = (await insGraph()).envelope; + check( + 'an Envelope node shows its device between its ports with its curve; a knob and a field each change it as one undo step', + envShown.shown && + envShown.size.w === 520 && + envShown.size.h === 280 && + Object.values(envShown.inputs).length === 7 && + Object.values(envShown.inputs).every((p) => p.x < envRect.x && envBeside(p)) && + Object.values(envShown.outputs).every((p) => p.x > envRect.x + envRect.w && envBeside(p)) && + envShown.trace.ends && + envShown.trace.stages.map((s) => s.key).join() === 'attack,decay' && + envShown.screen.ends === 'NOTE ENDS' && + envShown.range === '' && + envShown.idle.includes('from') && + envShown.idle.includes('to') && + envShown.knobs.decay.value === laser.envelope.decay && + envTurned.params.decay > laser.envelope.decay && + envTurned.panel.knobs.decay.value === envTurned.params.decay && + envTurned.panel.trace.stages[1].seconds === envTurned.params.decay && + envScrubbed.params.to > 1 && + envScrubbed.panel.fields.to.text === String(envScrubbed.params.to) && + JSON.stringify(envUndone) === JSON.stringify(laser.envelope), + JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }), + ); + const beforeRandom = await stats(); await clickHook('randomizeAt'); const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);