Show an Envelope node as a device with its ADSR curve (#388)
game-maker / gate (push) Successful in 4m24s
game-maker / suite (push) Successful in 15m50s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 16:06:15 +02:00
1 parent ab37d3423a
commit c75e72d4ee
9 files changed
+887 -5

No files matched your search

+6 -2
View File
@@ -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<Record<string, number>> = { [SoundFxFilter.type]: 4, [INSTRUMENT_CHIP]: 5 };
const COLUMNS_OF: Readonly<Record<string, number>> = {
[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);
+49
View File
@@ -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,
+73
View File
@@ -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);
}
}
+350
View File
@@ -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<EnvelopeKey>;
}
/** 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<string, Text>();
trace: EnvelopeTrace | null = null;
/** What the texts say now, by name (checks). */
readonly says = new Map<string, string>();
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<EnvelopeKey, { ink: Text; edge: Text }>;
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<EnvelopeKey> = 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 });
}
}
+232
View File
@@ -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<EnvelopeKey, number>;
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<EnvelopeKnobKey, EnvelopeKnob> = {
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<EnvelopeKey> {
const idle = new Set<EnvelopeKey>();
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<EnvelopeKnobKey, KnobSpot>;
/** A typed number by the middle of its box, its name engraved above as a knob's is. */
fields: Record<EnvelopeFieldKey, Box>;
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<EnvelopeKnobKey, KnobSpot>;
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<EnvelopeFieldKey, Box>;
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<StageKey, string> = { 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)}%`;
}
+4 -2
View File
@@ -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);