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, its right end lengthens * it, 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 sets the loop. 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 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 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; // 64d. Its body moves it onto the grid and to another key; with Alt it lands on the tick. await sleep(500); 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); const moved = (await notes()).find((n) => n.id === first); check( 'dragging a note moves it to the grid line and the key under the pointer', moved?.tick === 720 && moved?.note === 60, JSON.stringify(moved), ); 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 }), ); // 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 }), ); // 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 sets the loop; its menu takes it back to the whole pattern. p = await json('__gm.pattern.rulerAt(1920)'); to = await json('__gm.pattern.rulerAt(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.rulerAt(1920)'); to = await json('__gm.pattern.rulerAt(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 a click: it sets where playing starts and leaves the loop as it is. 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.beats = 4; p.props.beat = '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')], }, }); 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(); // 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'); 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, JSON.stringify(left), ); }