A song could only be heard in its tab. Now a game plays it through Play song and ends it through Stop song, with its tracks as they are set. A playing pattern or song also no longer undoes the pause of the toolbar. - Play song plays from the start of the song and then round the loop of its ruler, so an intro is heard once. Without loop it plays once to its end. Inputs are song, volume, fade in, loop (on by default) and detached (off by default). It gives out playback. - Stop song stops one song, every song for none, or one playback by its number, over its fade out. Stop sound with no sound named stops songs too. Stop pattern with no pattern named stops only patterns. - Songs play side by side, as patterns do. They are owned like a sound, so leaving the scene ends one unless it is detached. Pause game and the time scale leave the music alone. - Each playing is heard through a master of its own, silent until its first note. The fades move it, and a song's tracks sit inside it on buses of their own. They go once the playing and its voices have. - Notes and clips are struck the same way in the editor and the game (strikeNote, playClip). What a sequence needs comes from one place (core/music/needs). Findings: - Reading the audio clock resumed a suspended context. A playing pattern read it every 25 ms and so undid the toolbar's pause. The library now leaves a context alone while the toolbar holds it. - A pattern played once to its end asked the list of playings about its voices, and that list asked the pattern again. Once its last voice was gone this ran until the stack overflowed. The scheduler of a playing now asks only after voices. - e2e 64 reads the playings through __gm.sound.playbacks() now. Tests: - song-play.test.ts (new): 8 tests, node catalog +8 cases - sound-library.test.ts: 66 tests, 4 new - e2e 64 (melektaus-atx): 23 of 23 checks passed - e2e 65 (melektaus-atx): ALL PASSED, 22 checks (+4) - Gate (melektaus-atx): CHECK EXIT 0, 251 files, 3903 tests - Counter-checks: the old way of asking after voices, a clock read that wakes a held context, and a start after a fade out began each fail 1 test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
18 KiB
JavaScript
429 lines
18 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, 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),
|
||
);
|
||
}
|