Refactoring round 10 covers everything since round 9 (808e1ee): 110 commits with about 47 000 new lines, nearly all of them sound, sound FX, instruments, patterns and songs. Behaviour is unchanged. Duplicate code (jscpd over src, 50 tokens, TypeScript only, measured on808e1eeand here alike: 47 clones / 426 lines before, 33 / 245 after; round 9 itself ended at 22 / 160 by this count): - PreviewParams (new, editor/soundfx) is the header's parameter bar of the Sound FX and the Instrument tab: values, defaults, fields and the voices a moved field reaches. Both tabs had it line for line. - fxWorklet: an Effect base runs the bit crusher, phaser, flanger, limiter, LFO and bus shifter, a Source base the pitch player and the Chip. Start is the one wait for a voice's first sample, which four processors wrote out each. - nodes/shared has checkPort, detachedPort, fxParamsPort and letRingPort. The Play and Stop nodes of sounds, notes, patterns and songs spelled each port out, up to five times. - parameterValues builds both Sound FX parameters and Instrument parameters, which differed in name and text only. - SetLoopCommand sets the ruler loop of a pattern or a song. It replaces SetPatternLoopCommand and SetSongLoopCommand. - addLink in music/routing is the one rule for a link that both the song routing and the Master routing keep. - eqCurves works out each band and their sum for the EQ tab, which did that in its drawing code. It replaces eqCurveDb, which only a test called. - Smaller ones: the eight neighbour directions of a tile cell, and drawFloatingBox for the shadowed box of Popup and FloatWindow. - Left on purpose: Play pattern and Play song stay two parallel node definitions, and the smaller clones under ten lines stay. Architecture: - SoundLibrary.ts goes from 2897 to 2188 lines. What a stage of a sound FX becomes as Web Audio nodes, and how the knobs of a playing voice move, is now viewport/fxNodes.ts. None of it keeps state of the library. Dead code (TS language service, references per export and member): - soundRefIds, LFO_SHAPE_INDEX, TREMOLO_ROWS, VIBRATO_ROWS and REVERB_ROWS had no reader. The four routing node defs are registered without an unused exported name. - soundFileOf and portTypeOf were called by tests only. The tests now ask findSound and portOf, which the editor uses. DRY moves from src into the one test that uses it. - 51 values used only in their own file lose their `export`. Types and the node catalogues stay exported. - Kept on purpose: neighboursOf is the cell by cell reading the indexed neighboursIn is checked against, and PitchShifter.latency is the test's one view of how late the shifter is. Comments and docs: - Seven comments told history ("no longer", "any more", "as before", "of before #354"). Nine comment blocks ran far past the line width. Proof that this changed no code: the bundle was md5 identical, and the touched tests and e2e modules were identical after esbuild. - README: sound FX, instrument, pattern, song and bus in the asset list, the pages Instruments, Patterns and Songs, 1919 checks. - Wiki: see its own commit. Tests: Gate (melektaus-atx) 274 files, 4236 tests, CHECK EXIT 0 e2e 34, 64, 65 ALL PASSED, 180 checks Unit, touched areas 16 files, 804 tests No check was added, the e2e sum stays at 1919. Counter-checks, each put back by copy and compared with cmp: - addLink without the loop rule: 2 tests fail, one per routing. - eqCurves with the sum of the last band only: 1 test fails. - Start in the worklet never waiting: e2e 34 fails in exactly the four checks that hang on a voice's first sample (Flanger, Tremolo, Chip, a pitched note planned ahead). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
26 KiB
JavaScript
599 lines
26 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its
|
||
* keys sound where the instrument plays. A click draws a note on the grid, either end sizes it and
|
||
* lights up under the pointer, its body moves it – with Alt off the grid –, a band picks notes,
|
||
* Ctrl+D puts a copy behind them, the arrows transpose, Delete and a right-click take notes away,
|
||
* Ctrl+C/V copies them to the start set on the ruler, the lane sets a velocity, a drag on the ruler
|
||
* moves the start and one on the strip above it sets the loop, the wheel zooms the time or the
|
||
* rows. Space plays the pattern round its loop through the scheduler, and a note drawn meanwhile is
|
||
* planned without a restart. A game plays it through Play pattern, looped or once. Nothing is
|
||
* heard in CI (muted browser): the witnesses are the model, the planned notes and the sound
|
||
* counters, as in e2e 34.
|
||
*
|
||
* Everything it adds carries a `pt64` id or is the pattern it made, and goes again at the end.
|
||
*/
|
||
const BLIP_BYTES = 4044;
|
||
const INSTRUMENT = 'pt64-ins';
|
||
|
||
export async function pattern(t) {
|
||
const { check, sleep, json, evaluate, mutate, click, clickText, press, drag, wheel, shot, showTab } = t;
|
||
const { play, stop } = t;
|
||
const stats = () => json('__gm.sound.stats()');
|
||
const waitFor = async (read, done, tries = 25) => {
|
||
let value = await read();
|
||
for (let i = 0; i < tries && !done(value); i++) {
|
||
await sleep(150);
|
||
value = await read();
|
||
}
|
||
return value;
|
||
};
|
||
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
await showTab('scene');
|
||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
||
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||
await mutate(`
|
||
__gm.state.project.assets.push(
|
||
{ id: 'pt64-snd', type: 'sound', name: 'Blip 64', props: {}, file: ${JSON.stringify(blip?.file ?? '')} },
|
||
{
|
||
id: ${JSON.stringify(INSTRUMENT)},
|
||
type: 'instrument',
|
||
name: 'Lead 64',
|
||
props: {},
|
||
graph: {
|
||
nodes: ${JSON.stringify([
|
||
node('pt64-notes', 'InstrumentNotes', 0, 0, { from: 60, to: 71 }),
|
||
node('pt64-sample', 'InstrumentSample', 200, 0, { sound: 'pt64-snd' }),
|
||
node('pt64-out', 'InstrumentOutput', 420, 0),
|
||
])},
|
||
edges: ${JSON.stringify([
|
||
edge('pt64-e1', 'pt64-notes', 'any', 'pt64-sample', 'trigger'),
|
||
edge('pt64-e2', 'pt64-sample', 'out', 'pt64-out', 'in'),
|
||
])},
|
||
},
|
||
},
|
||
);`);
|
||
await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`);
|
||
|
||
// 64a. "+ → Pattern" makes an empty pattern and opens its tab.
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Pattern');
|
||
const created = await json(`{
|
||
tab: __gm.tab(),
|
||
current: __gm.pattern.current(),
|
||
info: __gm.pattern.info(),
|
||
patterns: __gm.state.project.assets.filter((a) => a.type === 'pattern').map((a) => ({ id: a.id, notes: a.notes.length })),
|
||
}`);
|
||
check(
|
||
'the + menu creates an empty pattern and opens its tab, which has no instrument yet',
|
||
created.tab === 'pattern' &&
|
||
created.patterns.length === 1 &&
|
||
created.current === created.patterns[0].id &&
|
||
created.patterns[0].notes === 0 &&
|
||
created.info === '0 notes · no instrument',
|
||
JSON.stringify(created),
|
||
);
|
||
const id = created.patterns[0]?.id;
|
||
const notes = () => json(`__gm.pattern.notes(${JSON.stringify(id)})`);
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).props.instrument = ${JSON.stringify(INSTRUMENT)};`,
|
||
);
|
||
const keyed = await json('{ info: __gm.pattern.info(), keys: __gm.pattern.keys() }');
|
||
const sounds = (n) => keyed.keys.find((k) => k.note === n)?.sounds;
|
||
check(
|
||
'with an instrument its keys sound from C4 to B4 and the others are grey',
|
||
keyed.info === '0 notes · Lead 64' &&
|
||
sounds(60) &&
|
||
sounds(71) &&
|
||
sounds(72) === false &&
|
||
sounds(59) === false,
|
||
JSON.stringify(keyed),
|
||
);
|
||
|
||
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and with Audition
|
||
// switched on it sounds.
|
||
const before = await json('__gm.pattern.view()');
|
||
const r0 = await json('__gm.pattern.rulerAt(0)');
|
||
await wheel(r0.x, r0.y, -600);
|
||
const zoomed = await json('__gm.pattern.view()');
|
||
check(
|
||
'the wheel on the ruler zooms the time about the pointer',
|
||
zoomed.pxPerTick > before.pxPerTick * 2 && Math.abs(zoomed.panX) < 1.5,
|
||
JSON.stringify({ before, zoomed }),
|
||
);
|
||
const auditionOff = await json('__gm.pattern.auditionAt()');
|
||
await click(auditionOff.x, auditionOff.y);
|
||
const auditionOn = await json('__gm.pattern.auditionAt()');
|
||
check(
|
||
'Audition in the header is off at first and a click switches it on',
|
||
auditionOff.toggled === false && auditionOn.toggled === true,
|
||
JSON.stringify({ auditionOff, auditionOn }),
|
||
);
|
||
const heard = await stats();
|
||
let p = await json('__gm.pattern.gridAt(250, 62)');
|
||
await click(p.x, p.y);
|
||
const drawn = await notes();
|
||
const drawnSound = await waitFor(stats, (s) => s.started > heard.started);
|
||
check(
|
||
'a click draws a 1/16 on the grid line before the pointer, picks it and lets it sound',
|
||
drawn.length === 1 &&
|
||
drawn[0].tick === 240 &&
|
||
drawn[0].length === 120 &&
|
||
drawn[0].note === 62 &&
|
||
drawn[0].velocity === 0.8 &&
|
||
(await json('__gm.pattern.picked()')).join() === drawn[0].id &&
|
||
drawnSound.started > heard.started,
|
||
JSON.stringify({ drawn, drawnSound, heard }),
|
||
);
|
||
const first = drawn[0].id;
|
||
|
||
// 64c. Its right end takes it to the grid line nearest the pointer; the next note drawn is as long.
|
||
const end = await json('__gm.pattern.gridAt(360, 62)');
|
||
p = await json('__gm.pattern.gridAt(720, 62)');
|
||
await drag(end.x - 2, end.y, p.x, p.y);
|
||
await sleep(500);
|
||
p = await json('__gm.pattern.gridAt(1000, 64)');
|
||
await click(p.x, p.y);
|
||
const lengths = await notes();
|
||
check(
|
||
'dragging the end lengthens it to 1/4, and the next note drawn takes that length',
|
||
lengths.length === 2 &&
|
||
lengths.find((n) => n.id === first)?.length === 480 &&
|
||
lengths.find((n) => n.id !== first)?.tick === 960 &&
|
||
lengths.find((n) => n.id !== first)?.length === 480,
|
||
JSON.stringify(lengths),
|
||
);
|
||
const second = lengths.find((n) => n.id !== first)?.id;
|
||
|
||
// 64c'. Its left end lights up under the pointer and takes its start to the nearest grid line, the
|
||
// end staying put; Ctrl+Z takes it back.
|
||
const startEdge = await json('__gm.pattern.gridAt(960, 64)');
|
||
await t.mouse('mouseMoved', startEdge.x + 2, startEdge.y);
|
||
await sleep(100);
|
||
const hovered = await json('__gm.pattern.view()');
|
||
const earlier = await json('__gm.pattern.gridAt(850, 64)');
|
||
await drag(startEdge.x + 2, startEdge.y, earlier.x, earlier.y);
|
||
await sleep(300);
|
||
const trimmedNote = (await notes()).find((n) => n.id === second);
|
||
await press('z', { ctrl: true });
|
||
const untrimmed = (await notes()).find((n) => n.id === second);
|
||
check(
|
||
'the left end of a note lights up with a resize cursor and takes its start to the nearest grid line, the end kept',
|
||
hovered.hover?.id === second &&
|
||
hovered.hover?.part === 'start' &&
|
||
hovered.cursor === 'ew-resize' &&
|
||
trimmedNote?.tick === 840 &&
|
||
trimmedNote?.length === 600 &&
|
||
untrimmed?.tick === 960 &&
|
||
untrimmed?.length === 480,
|
||
JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, trimmedNote, untrimmed }),
|
||
);
|
||
|
||
// 64d. Its body moves it onto the grid and to another key, silent with Audition off; with Alt it
|
||
// lands on the tick.
|
||
await sleep(500);
|
||
const audition = await json('__gm.pattern.auditionAt()');
|
||
await click(audition.x, audition.y);
|
||
const quiet = await stats();
|
||
p = await json('__gm.pattern.gridAt(300, 62)');
|
||
let to = await json('__gm.pattern.gridAt(780, 60)');
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
await sleep(300);
|
||
const moved = (await notes()).find((n) => n.id === first);
|
||
const movedStats = await stats();
|
||
check(
|
||
'dragging a note moves it to the grid line and the key under the pointer, silent with Audition off',
|
||
moved?.tick === 720 && moved?.note === 60 && movedStats.started === quiet.started,
|
||
JSON.stringify({ moved, quiet, movedStats }),
|
||
);
|
||
await sleep(500);
|
||
p = await json('__gm.pattern.gridAt(800, 60)');
|
||
to = await json('__gm.pattern.gridAt(850, 60)');
|
||
await drag(p.x, p.y, to.x, to.y, { modifiers: { alt: true } });
|
||
const free = (await notes()).find((n) => n.id === first);
|
||
await press('z', { ctrl: true });
|
||
const back = (await notes()).find((n) => n.id === first);
|
||
check(
|
||
'with Alt held it moves off the grid, to the tick, and Ctrl+Z takes it back',
|
||
free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720,
|
||
JSON.stringify({ free, back }),
|
||
);
|
||
// Shift does the same; a Shift click that does not move still takes the note out of the picked ones.
|
||
await drag(p.x, p.y, to.x, to.y, { modifiers: { shift: true } });
|
||
const shiftFree = (await notes()).find((n) => n.id === first);
|
||
const shiftPicked = await json('__gm.pattern.picked()');
|
||
await press('z', { ctrl: true });
|
||
await click(p.x, p.y, 'left', { shift: true });
|
||
const unpicked = await json('__gm.pattern.picked()');
|
||
await click(p.x, p.y);
|
||
check(
|
||
'with Shift held it moves off the grid too, and a Shift click still unpicks the note',
|
||
shiftFree?.tick % 120 !== 0 &&
|
||
Math.abs(shiftFree.tick - 770) <= 6 &&
|
||
shiftPicked.includes(first) &&
|
||
!unpicked.includes(first),
|
||
JSON.stringify({ shiftFree, shiftPicked, unpicked }),
|
||
);
|
||
|
||
// 64e. A band picks both; Ctrl+D puts a copy right behind them, picked; the arrows transpose it.
|
||
p = await json('__gm.pattern.gridAt(100, 67)');
|
||
to = await json('__gm.pattern.gridAt(1600, 58)');
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
const banded = await json('__gm.pattern.picked()');
|
||
await press('d', { ctrl: true });
|
||
const doubled = await notes();
|
||
const copies = await json('__gm.pattern.picked()');
|
||
const copyOf = (n) => doubled.find((c) => copies.includes(c.id) && c.note === n);
|
||
check(
|
||
'a band picks the notes it touches, and Ctrl+D copies them right behind, picking the copies',
|
||
banded.length === 2 &&
|
||
banded.includes(first) &&
|
||
banded.includes(second) &&
|
||
doubled.length === 4 &&
|
||
copies.length === 2 &&
|
||
!copies.includes(first) &&
|
||
copyOf(60)?.tick === 1440 &&
|
||
copyOf(64)?.tick === 1680,
|
||
JSON.stringify({ banded, doubled, copies }),
|
||
);
|
||
await press('ArrowUp');
|
||
const up = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note);
|
||
await press('ArrowUp', { shift: true });
|
||
const octave = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note);
|
||
await press('ArrowDown', { shift: true });
|
||
check(
|
||
'an arrow transposes the picked notes a semitone, with Shift an octave',
|
||
up.join() === '61,65' && octave.join() === '73,77',
|
||
JSON.stringify({ up, octave }),
|
||
);
|
||
|
||
// 64f. Delete takes the picked notes, Ctrl+Z brings them back.
|
||
await press('Delete');
|
||
const deleted = await notes();
|
||
await press('z', { ctrl: true });
|
||
const restored = await notes();
|
||
check(
|
||
'Delete takes the picked notes away and Ctrl+Z brings them back',
|
||
deleted.length === 2 && restored.length === 4,
|
||
JSON.stringify({ deleted: deleted.length, restored: restored.length }),
|
||
);
|
||
|
||
// 64g. Ctrl+A, Ctrl+C; a click on the ruler at bar 2 sets the start; Ctrl+V lands there.
|
||
await press('a', { ctrl: true });
|
||
await press('c', { ctrl: true });
|
||
const bar2 = await json('__gm.pattern.rulerAt(1920)');
|
||
await click(bar2.x + 3, bar2.y);
|
||
const startSet = (await json('__gm.pattern.view()')).start;
|
||
await press('v', { ctrl: true });
|
||
const pasted = await notes();
|
||
const pastedIds = await json('__gm.pattern.picked()');
|
||
const pastedTicks = pasted.filter((n) => pastedIds.includes(n.id)).map((n) => n.tick);
|
||
check(
|
||
'a click on the ruler sets where playing starts, and Ctrl+V pastes the copied notes there',
|
||
startSet === 1920 && pasted.length === 8 && pastedIds.length === 4 && Math.min(...pastedTicks) === 1920,
|
||
JSON.stringify({ startSet, count: pasted.length, pastedTicks }),
|
||
);
|
||
// With the pointer over the grid the paste lands there instead: the earliest note on the grid line
|
||
// before it, the highest on its row. Ctrl+Z takes it back.
|
||
const copiedHigh = Math.max(...pasted.filter((n) => pastedIds.includes(n.id)).map((n) => n.note));
|
||
p = await json('__gm.pattern.gridAt(2930, 70)');
|
||
await t.mouse('mouseMoved', p.x, p.y);
|
||
await press('v', { ctrl: true });
|
||
const atPointer = await notes();
|
||
const atPointerIds = await json('__gm.pattern.picked()');
|
||
const landed = atPointer.filter((n) => atPointerIds.includes(n.id));
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'with the pointer over the grid Ctrl+V pastes there: the earliest note at its grid line, the highest on its row',
|
||
atPointer.length === 12 &&
|
||
Math.min(...landed.map((n) => n.tick)) === 2880 &&
|
||
Math.max(...landed.map((n) => n.note)) === 70 &&
|
||
(await notes()).length === 8,
|
||
JSON.stringify({ copiedHigh, landed: landed.map((n) => [n.tick, n.note]) }),
|
||
);
|
||
|
||
// 64h. A right-click takes a note away; the lane sets the velocity of the one it grabs.
|
||
p = await json('__gm.pattern.gridAt(1980, 60)');
|
||
await click(p.x, p.y, 'right');
|
||
const erased = await notes();
|
||
const lane = await json('__gm.pattern.laneAt(720, 0.8)');
|
||
const low = await json('__gm.pattern.laneAt(720, 0.25)');
|
||
await drag(lane.x + 1, lane.y + 2, low.x + 1, low.y);
|
||
const soft = await notes();
|
||
check(
|
||
'a right-click deletes the note under it, and dragging its bar in the lane sets its velocity alone',
|
||
erased.length === 7 &&
|
||
Math.abs(soft.find((n) => n.id === first).velocity - 0.25) < 0.04 &&
|
||
soft.filter((n) => n.id !== first).every((n) => n.velocity === 0.8),
|
||
JSON.stringify({ count: erased.length, velocities: soft.map((n) => n.velocity) }),
|
||
);
|
||
|
||
// 64i. A drag on the ruler moves only where playing starts; one on the strip above sets the loop,
|
||
// its menu takes it back to the whole pattern. The wheel zooms the time on the grid, the rows on the keys.
|
||
p = await json('__gm.pattern.rulerAt(960)');
|
||
to = await json('__gm.pattern.rulerAt(2400)');
|
||
await drag(p.x + 2, p.y, to.x + 2, to.y);
|
||
const scrubbed = await json(
|
||
`{ start: __gm.pattern.view().start, loop: __gm.pattern.loop(${JSON.stringify(id)}) }`,
|
||
);
|
||
const wheelFrom = await json('__gm.pattern.view()');
|
||
p = await json('__gm.pattern.gridAt(960, 62)');
|
||
await wheel(p.x, p.y, -200);
|
||
const gridWheel = await json('__gm.pattern.view()');
|
||
p = await json('__gm.pattern.keyAt(62)');
|
||
await wheel(p.x, p.y, -200);
|
||
const keysWheel = await json('__gm.pattern.view()');
|
||
await wheel(p.x, p.y, 200);
|
||
p = await json('__gm.pattern.gridAt(960, 62)');
|
||
await wheel(p.x, p.y, 200);
|
||
check(
|
||
'a drag on the ruler moves where playing starts and leaves the loop; the wheel zooms the time on the grid, the rows on the keys',
|
||
scrubbed.start === 2400 &&
|
||
scrubbed.loop === null &&
|
||
gridWheel.pxPerTick > wheelFrom.pxPerTick &&
|
||
gridWheel.rowHeight === wheelFrom.rowHeight &&
|
||
Math.abs(gridWheel.panY - wheelFrom.panY) < 1e-6 &&
|
||
keysWheel.rowHeight > wheelFrom.rowHeight &&
|
||
keysWheel.pxPerTick === gridWheel.pxPerTick &&
|
||
keysWheel.panX === gridWheel.panX,
|
||
JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }),
|
||
);
|
||
// A middle-drag far to the right pans tick 0 in from the left edge, but only half the grid in; a
|
||
// click left of it draws nothing. Fit takes the view back.
|
||
const area = await json('__gm.pattern.rect()');
|
||
p = await json('__gm.pattern.gridAt(960, 62)');
|
||
for (let i = 0; i < 4; i++) await drag(area.x + 120, p.y, area.x + area.w - 20, p.y, { button: 'middle' });
|
||
const panned = await json('__gm.pattern.view()');
|
||
const zero = await json('__gm.pattern.gridAt(0, 62)');
|
||
const countPanned = (await notes()).length;
|
||
await click(zero.x - 20, zero.y);
|
||
const countLeft = (await notes()).length;
|
||
// Down the same: the highest note comes half the grid's height down at most (the grid is the area
|
||
// less the 30 px ruler and the 64 px velocity lane), and a click above it draws nothing.
|
||
const gridHeight = area.h - 30 - 64;
|
||
for (let i = 0; i < 16; i++) {
|
||
const before = (await json('__gm.pattern.view()')).panY;
|
||
await drag(zero.x + 40, area.y + 40, zero.x + 40, area.y + area.h - 70, { button: 'middle' });
|
||
if ((await json('__gm.pattern.view()')).panY === before) break;
|
||
}
|
||
const pannedDown = await json('__gm.pattern.view()');
|
||
await click(zero.x + 40, area.y + 40);
|
||
const countAbove = (await notes()).length;
|
||
p = await json('__gm.pattern.fitAt()');
|
||
await click(p.x, p.y);
|
||
const fitted = await json('__gm.pattern.view()');
|
||
check(
|
||
'a middle-drag pans bar 1 in from the left and the top row down, half the grid at most, and a click outside draws nothing',
|
||
Math.abs(panned.panX - (area.w - 84) / 2) < 1 &&
|
||
countLeft === countPanned &&
|
||
Math.abs(pannedDown.panY - gridHeight / 2) < 1 &&
|
||
countAbove === countPanned,
|
||
JSON.stringify({ panned, pannedDown, area, countPanned, countLeft, countAbove }),
|
||
);
|
||
// The notes span few keys: Fit zooms the rows past the default of 14 px to fill the height.
|
||
check(
|
||
'Fit zooms the rows too, so the notes fill the height',
|
||
fitted.rowHeight > 14,
|
||
JSON.stringify(fitted),
|
||
);
|
||
// The checks below play keys up to 69: the wheel over the keys takes the rows out until they show.
|
||
for (let i = 0; i < 8; i++) {
|
||
const high = await json('__gm.pattern.keyAt(70)');
|
||
const low = await json('__gm.pattern.keyAt(59)');
|
||
if (high.y > area.y + 50 && low.y < area.y + area.h - 70) break;
|
||
await wheel(low.x, (low.y + high.y) / 2, 200);
|
||
}
|
||
p = await json('__gm.pattern.loopAt(1920)');
|
||
to = await json('__gm.pattern.loopAt(2880)');
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
const looped = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||
await click(p.x + 20, p.y, 'right');
|
||
await clickText('Loop the whole pattern');
|
||
const whole = await json(
|
||
`{ loop: __gm.pattern.loop(${JSON.stringify(id)}), view: __gm.pattern.view().loop }`,
|
||
);
|
||
check(
|
||
'a drag on the ruler sets the loop on the grid, and "Loop the whole pattern" takes it away',
|
||
looped?.start === 1920 &&
|
||
looped?.end === 2880 &&
|
||
whole.loop === null &&
|
||
whole.view.start === 0 &&
|
||
whole.view.end === 7680,
|
||
JSON.stringify({ looped, whole }),
|
||
);
|
||
|
||
// 64j. Space plays bar 1 round its loop from the start, each note at its tick on the audio clock;
|
||
// a note drawn while it plays is planned in the next round, without starting over.
|
||
p = await json('__gm.pattern.loopAt(1920)');
|
||
to = await json('__gm.pattern.loopAt(0)');
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
const bar1 = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||
// At the loop's start, but on the ruler below it: it sets where playing starts and leaves the loop as it is.
|
||
to = await json('__gm.pattern.rulerAt(0)');
|
||
await click(to.x + 2, to.y);
|
||
await press('Space');
|
||
const planned = await waitFor(
|
||
() => json('__gm.pattern.planned()'),
|
||
(list) => list.length >= 4,
|
||
30,
|
||
);
|
||
const playing = await json('{ playing: __gm.pattern.playing(), button: __gm.pattern.playAt().text }');
|
||
const at = (tick) => planned.find((n) => Math.abs(n.at - planned[0].at - (tick - 720) / 960) < 1e-6);
|
||
check(
|
||
'Space plays the loop of bar 1 from its start: every note planned at its tick on the audio clock',
|
||
bar1?.start === 0 &&
|
||
bar1?.end === 1920 &&
|
||
playing.playing &&
|
||
playing.button === 'Stop' &&
|
||
planned[0].note === 60 &&
|
||
planned[0].instrument === INSTRUMENT &&
|
||
at(960)?.note === 64 &&
|
||
at(1440)?.note === 61 &&
|
||
at(1680)?.note === 65,
|
||
JSON.stringify({
|
||
bar1,
|
||
playing,
|
||
planned: planned.map((n) => [n.note, +(n.at - planned[0].at).toFixed(4)]),
|
||
}),
|
||
);
|
||
const head = await json('__gm.pattern.position()');
|
||
p = await json('__gm.pattern.gridAt(1800, 69)');
|
||
await click(p.x, p.y);
|
||
const live = await waitFor(
|
||
() => json('__gm.pattern.planned()'),
|
||
(list) => list.some((n) => n.note === 69),
|
||
30,
|
||
);
|
||
check(
|
||
'a note drawn while it plays is heard in the next round, the playing goes on',
|
||
live.some((n) => n.note === 69) && live.length > planned.length && (await json('__gm.pattern.playing()')),
|
||
JSON.stringify({ head, live: live.map((n) => n.note) }),
|
||
);
|
||
await shot('64-pattern');
|
||
await press('Space');
|
||
const stopped = await waitFor(
|
||
async () => ({
|
||
playing: await json('__gm.pattern.playing()'),
|
||
stats: await stats(),
|
||
view: await json('__gm.pattern.view()'),
|
||
}),
|
||
(s) => s.stats.playing === 0,
|
||
);
|
||
check(
|
||
'Space again stops it, its voices fade and the playhead goes',
|
||
!stopped.playing &&
|
||
stopped.stats.playing === 0 &&
|
||
stopped.view.playhead === null &&
|
||
(await json('__gm.pattern.playAt().text')) === 'Play',
|
||
JSON.stringify(stopped),
|
||
);
|
||
|
||
// 64k. A key down the left sounds while it is held.
|
||
const keyBefore = await stats();
|
||
const key = await json('__gm.pattern.keyAt(67)');
|
||
await t.mouse('mouseMoved', key.x, key.y);
|
||
await t.mouse('mousePressed', key.x, key.y);
|
||
const held = await waitFor(stats, (s) => s.started > keyBefore.started);
|
||
const heldKey = (await json('__gm.pattern.view()')).keys;
|
||
await t.mouse('mouseReleased', key.x, key.y);
|
||
check(
|
||
'a key of the roll sounds while it is held and is lit',
|
||
held.started > keyBefore.started && heldKey === 67 && (await json('__gm.pattern.view()')).keys === null,
|
||
JSON.stringify({ held, heldKey }),
|
||
);
|
||
|
||
// 64l. A game plays the pattern through Play pattern: a bar of quarters at 150 BPM, its notes at
|
||
// their ticks on the audio clock and round and round, owned by the behavior. Ending the run
|
||
// ends it; played once, it goes by itself after its bar.
|
||
await mutate(`
|
||
const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
p.props.tempo = 150;
|
||
p.props.bars = 1;
|
||
p.props.sign = '4/4';
|
||
p.notes = [60, 62, 64, 65].map((note, i) => ({ id: 'pt64-n' + i, tick: i * 480, length: 240, note, velocity: 1 }));`);
|
||
await showTab('scene');
|
||
const rig = await scaffoldBehavior(t, {
|
||
assetId: 'pt64-logic',
|
||
name: 'Music 64',
|
||
boxId: 'pt64-box',
|
||
logicId: 'pt64-brain',
|
||
y: LANES.sound,
|
||
graph: {
|
||
nodes: [
|
||
node('pt64-start', 'OnStart', 0, 0),
|
||
node('pt64-play', 'PlayPattern', 260, 0, { pattern: id, volume: 1, loop: true }),
|
||
],
|
||
edges: [edge('pt64-e3', 'pt64-start', 'exec', 'pt64-play', 'exec')],
|
||
},
|
||
});
|
||
// A behavior running in the editor ends when the game starts, and its sounds with it – but only
|
||
// its own: the pattern the game's `On start` plays in the same click has to live on.
|
||
const idle = await scaffoldBehavior(t, {
|
||
assetId: 'pt64-idle',
|
||
name: 'Idle 64',
|
||
boxId: 'pt64-idlebox',
|
||
logicId: 'pt64-idler',
|
||
y: LANES.sound + 200,
|
||
props: { enabled: true, runInEditor: true },
|
||
graph: { nodes: [node('pt64-idle-start', 'OnStart', 0, 0)], edges: [] },
|
||
});
|
||
await waitFor(
|
||
() => json('__gm.editorRun().running'),
|
||
(running) => running,
|
||
);
|
||
const patterns = () => json('__gm.sound.playbacks()');
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await play();
|
||
const game = await waitFor(patterns, (list) => list[0]?.planned.length >= 6, 40);
|
||
const round = game[0]?.planned ?? [];
|
||
const gaps = round.slice(1, 6).map((n, i) => +(n.at - round[i].at).toFixed(6));
|
||
check(
|
||
'Play pattern plays it in the game round and round, its quarters 0.4 s apart at 150 BPM, owned by the behavior',
|
||
game.length === 1 &&
|
||
game[0].name === `pattern:${id}` &&
|
||
game[0].owner !== '' &&
|
||
game[0].playing &&
|
||
round
|
||
.slice(0, 6)
|
||
.map((n) => n.note)
|
||
.join() === '60,62,64,65,60,62' &&
|
||
round.every(
|
||
(n) => n.instrument === INSTRUMENT && n.tempo === 150 && Math.abs(n.duration - 0.2) < 1e-9,
|
||
) &&
|
||
gaps.every((gap) => Math.abs(gap - 0.4) < 1e-6),
|
||
JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gaps }),
|
||
);
|
||
await stop();
|
||
const ended = await waitFor(
|
||
async () => ({ patterns: await patterns(), stats: await stats() }),
|
||
(s) => s.patterns.length === 0 && s.stats.playing === 0,
|
||
);
|
||
check(
|
||
'stopping the game ends the pattern and its notes',
|
||
ended.patterns.length === 0 && ended.stats.playing === 0,
|
||
JSON.stringify(ended),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'pt64-logic').graph;
|
||
graph.nodes.find((n) => n.id === 'pt64-play').params.loop = false;`);
|
||
await play();
|
||
const once = await waitFor(patterns, (list) => list[0]?.planned.length >= 1, 40);
|
||
const gone = await waitFor(patterns, (list) => list.length === 0, 40);
|
||
const onceStats = await stats();
|
||
await stop();
|
||
check(
|
||
'played once, it plays its bar and goes by itself while the game runs on',
|
||
once.length === 1 && gone.length === 0 && once[0].name === `pattern:${id}` && onceStats.playing === 0,
|
||
JSON.stringify({ once: once.map((g) => g.planned.length), gone, onceStats }),
|
||
);
|
||
const left = await rig.remove();
|
||
const idleLeft = await idle.remove();
|
||
|
||
// 64m. The step takes back everything it made.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await mutate(`
|
||
const gone = new Set(['pt64-snd', ${JSON.stringify(INSTRUMENT)}, ${JSON.stringify(id)}]);
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
__gm.state.select(null);`);
|
||
await showTab('scene');
|
||
// Only its own editor run: in CI a step before this one leaves one of its own running.
|
||
const editorRun = await json('__gm.editorRun()');
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await json(
|
||
`__gm.state.project.assets.filter((a) => a.id.startsWith('pt64') || a.type === 'pattern').length`,
|
||
)) === 0 &&
|
||
left.objects === 0 &&
|
||
left.assets === 0 &&
|
||
idleLeft.objects === 0 &&
|
||
idleLeft.assets === 0 &&
|
||
!editorRun.behaviors.some((id) => id.startsWith('pt64')),
|
||
JSON.stringify({ left, idleLeft, editorRun }),
|
||
);
|
||
}
|