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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab37d3423a
commit
c75e72d4ee
9 files changed
+887
-5
No files matched your search
@@ -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%');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user