A pattern had two rows for its meter, Beats per bar and Beat, while a song has had one row Sign since #363. Both now pick from the same list. - The list of signs and how a sign becomes a meter moved to time.ts. Song and pattern share it. - A pattern keeps its meter in `sign`. The fields `beats` and `beat` are gone, a sign that is not in the list counts as 4/4. - Create pattern in a song hands the song's sign to the new pattern. - The four patterns of cbbf160bb7e8 were moved to `sign` by hand (three 4/4, one 3/4). The other projects only had 4/4, which stays the default. Tests: - Unit pattern/embedded: 30 green (1 new, 2 rewritten), the counter-test reading `beats` again turns 2 red - e2e 64 + 65 (melektaus-atx): ALL PASSED, 85 checks - Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4215 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
598 lines
26 KiB
JavaScript
598 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 old 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 }),
|
||
);
|
||
}
|