/** * 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%'); }); });