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

+128
View File
@@ -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 => ({
...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%');
});
});