The routing of a song is the same graph editor as a sound FX, but its tab had no magnet, so its nodes could not be put on the grid. - The Song tab carries the magnet next to Routing. It is off at first and acts on the routing graph. - It is greyed out on the timeline, as Grid is greyed out in the routing. Switching the view only updates these two buttons, so a playing song is not handed its sequence again. - The check hooks give the magnet and the snapping of the routing. Tests: - e2e 65 (melektaus-atx): ALL PASSED, 52 checks (1 new) - Counter-check (magnet enabled on the timeline): exactly this check red - Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1224 lines
54 KiB
JavaScript
1224 lines
54 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds
|
||
* three more. A pattern and a
|
||
* sound dragged in from the Assets panel land as items where the preview showed them; an item's
|
||
* right end lengthens it, its body moves it across tracks, its left end cuts it back or pulls it
|
||
* out earlier, an end snaps to the end of a round, Ctrl+V lands under the pointer, Ctrl+D puts a
|
||
* copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click
|
||
* on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the
|
||
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
|
||
* the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler:
|
||
* the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus.
|
||
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record
|
||
* takes chromium's test tone onto a track of its own (#342), Esc throws a take away. A right-click
|
||
* on an empty spot of a track makes a pattern the song keeps to itself (#355).
|
||
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
|
||
* and what the buses were set to, as in e2e 64.
|
||
*
|
||
* Everything it adds carries a `sg65` id or is the song it made, and goes again at the end.
|
||
*/
|
||
const BLIP_BYTES = 4044;
|
||
const BAR = 1920;
|
||
const PATTERN = 'sg65-pat';
|
||
const SOUND = 'sg65-snd';
|
||
|
||
export async function song(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
mouse,
|
||
click,
|
||
clickText,
|
||
doubleClick,
|
||
press,
|
||
drag,
|
||
shot,
|
||
showTab,
|
||
play,
|
||
stop,
|
||
clickToolbar,
|
||
} = t;
|
||
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;
|
||
};
|
||
/** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */
|
||
const dragAsset = async (assetId, to) => {
|
||
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
|
||
await mouse('mouseMoved', from.x, from.y);
|
||
await mouse('mousePressed', from.x, from.y);
|
||
for (let i = 1; i <= 8; i++) {
|
||
await mouse(
|
||
'mouseMoved',
|
||
from.x + ((to.x - from.x) * i) / 8,
|
||
from.y + ((to.y - from.y) * i) / 8,
|
||
'left',
|
||
{
|
||
buttons: 1,
|
||
},
|
||
);
|
||
await sleep(16);
|
||
}
|
||
const drop = (await json('__gm.song.view()')).drop;
|
||
await mouse('mouseReleased', to.x, to.y);
|
||
await sleep(200);
|
||
return drop;
|
||
};
|
||
|
||
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: ${JSON.stringify(SOUND)}, type: 'sound', name: 'Blip 65', props: {}, file: ${JSON.stringify(blip?.file ?? '')} },
|
||
{
|
||
id: 'sg65-ins',
|
||
type: 'instrument',
|
||
name: 'Lead 65',
|
||
props: {},
|
||
graph: {
|
||
nodes: ${JSON.stringify([
|
||
node('sg65-notes', 'InstrumentNotes', 0, 0, { from: 48, to: 84 }),
|
||
node('sg65-sample', 'InstrumentSample', 200, 0, { sound: SOUND }),
|
||
node('sg65-out', 'InstrumentOutput', 420, 0),
|
||
])},
|
||
edges: ${JSON.stringify([
|
||
edge('sg65-e1', 'sg65-notes', 'any', 'sg65-sample', 'trigger'),
|
||
edge('sg65-e2', 'sg65-sample', 'out', 'sg65-out', 'in'),
|
||
])},
|
||
},
|
||
},
|
||
{
|
||
id: ${JSON.stringify(PATTERN)},
|
||
type: 'pattern',
|
||
name: 'Riff 65',
|
||
props: { instrument: 'sg65-ins', bars: 1 },
|
||
notes: [
|
||
{ id: 'sg65-n1', tick: 0, length: 480, note: 60, velocity: 1 },
|
||
{ id: 'sg65-n2', tick: 960, length: 480, note: 64, velocity: 0.5 },
|
||
],
|
||
},
|
||
);`);
|
||
const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`);
|
||
|
||
// 65a. "+ → Song" makes a song of one empty track and opens its tab; a head's menu adds the rest.
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Song');
|
||
const created = await json(`{
|
||
tab: __gm.tab(),
|
||
current: __gm.song.current(),
|
||
info: __gm.song.info(),
|
||
songs: __gm.state.project.assets.filter((a) => a.type === 'song').map((a) => ({ id: a.id, tracks: a.tracks.length })),
|
||
}`);
|
||
check(
|
||
'the + menu creates a song of one empty track and opens its tab',
|
||
created.tab === 'song' &&
|
||
created.songs.length === 1 &&
|
||
created.current === created.songs[0].id &&
|
||
created.songs[0].tracks === 1 &&
|
||
created.info === '1 track · 0 items',
|
||
JSON.stringify(created),
|
||
);
|
||
const id = created.songs[0]?.id;
|
||
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
|
||
/** Adds a track under the last one through its head's menu: there is no other way to. */
|
||
const addTrackBelowLast = async () => {
|
||
const count = (await model()).tracks.length;
|
||
const head = await json(`__gm.song.headAt(${count - 1}, 'name')`);
|
||
await click(head.x, head.y, 'right');
|
||
await sleep(150);
|
||
await clickText('Add track below');
|
||
await sleep(100);
|
||
};
|
||
for (let i = 0; i < 3; i++) await addTrackBelowLast();
|
||
const tracks = (await model()).tracks.map((track) => track.id);
|
||
|
||
// 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars.
|
||
// A pattern dragged onto the second track lands on the bar before the pointer, a bar long.
|
||
const fitted = await json('__gm.song.view()');
|
||
const area = await json('__gm.song.rect()');
|
||
for (let i = 0; i < 30; i++) {
|
||
const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`);
|
||
if (far.x < area.x + area.w) break;
|
||
const near = await json(`__gm.song.laneAt(0, 0)`);
|
||
await t.wheel(near.x + 2, near.y, 200);
|
||
}
|
||
const zoomedOut = await json('__gm.song.view()');
|
||
check(
|
||
'an empty song fits a single bar across, and the wheel over the lanes takes the view out',
|
||
fitted.pxPerTick > zoomedOut.pxPerTick * 4,
|
||
JSON.stringify({ fitted, zoomedOut }),
|
||
);
|
||
const preview = await dragAsset(PATTERN, await json(`__gm.song.laneAt(${BAR + 400}, 1)`));
|
||
let items = (await model()).items;
|
||
const riff = items[0];
|
||
check(
|
||
'a pattern dragged in from the Assets panel shows where it lands and lands there, picked',
|
||
preview?.kind === 'pattern' &&
|
||
preview.track === tracks[1] &&
|
||
preview.tick === BAR &&
|
||
items.length === 1 &&
|
||
riff.kind === 'pattern' &&
|
||
riff.asset === PATTERN &&
|
||
riff.track === tracks[1] &&
|
||
riff.tick === BAR &&
|
||
riff.length === BAR &&
|
||
(await json('__gm.song.picked()')).join() === riff.id &&
|
||
(await json('__gm.state.selection?.kind')) === 'song',
|
||
JSON.stringify({ preview, items }),
|
||
);
|
||
|
||
// 65c. A sound dragged onto the first track is as long as it plays at 120 BPM.
|
||
await dragAsset(SOUND, await json('__gm.song.laneAt(300, 0)'));
|
||
items = (await model()).items;
|
||
const clip = items.find((item) => item.kind === 'sound');
|
||
const drawn = await waitFor(
|
||
() => json('__gm.song.items()'),
|
||
(list) => list.find((item) => item.id === clip?.id)?.content === 'wave',
|
||
);
|
||
check(
|
||
'a sound dropped on a track lasts as long as the sound at the tempo, and shows its waveform',
|
||
clip?.track === tracks[0] &&
|
||
clip.tick === 0 &&
|
||
clip.length === Math.max(1, Math.round(seconds * 960)) &&
|
||
drawn.find((item) => item.id === riff.id)?.content === 'notes' &&
|
||
drawn.find((item) => item.id === clip.id)?.label === 'Blip 65',
|
||
JSON.stringify({ seconds, clip, drawn }),
|
||
);
|
||
await shot('01-dropped');
|
||
|
||
// 65d. The pattern's right end takes it to three bars, its body to the third track a bar later.
|
||
let p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'end')`);
|
||
let to = await json(`__gm.song.laneAt(${4 * BAR}, 1)`);
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
await sleep(200);
|
||
const lengthened = (await model()).items.find((item) => item.id === riff.id);
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`);
|
||
const { pxPerTick } = await json('__gm.song.view()');
|
||
to = await json(`__gm.song.laneAt(0, 2)`);
|
||
await drag(p.x, p.y, p.x + BAR * pxPerTick, to.y);
|
||
await sleep(200);
|
||
const movedRiff = (await model()).items.find((item) => item.id === riff.id);
|
||
check(
|
||
'its right end lengthens the pattern to three bars, its body takes it a bar on and a track down',
|
||
lengthened?.length === 3 * BAR && movedRiff?.tick === 2 * BAR && movedRiff.track === tracks[2],
|
||
JSON.stringify({ lengthened, movedRiff }),
|
||
);
|
||
|
||
// 65e. Its left end cuts a bar off the front: it then starts a bar into what it repeats.
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`);
|
||
to = await json(`__gm.song.laneAt(${3 * BAR}, 2)`);
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
await sleep(200);
|
||
const trimmedRiff = (await model()).items.find((item) => item.id === riff.id);
|
||
check(
|
||
'its left end cuts the front off, and what it plays starts that far in',
|
||
trimmedRiff?.tick === 3 * BAR && trimmedRiff.length === 2 * BAR && trimmedRiff.offset === BAR,
|
||
JSON.stringify(trimmedRiff),
|
||
);
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'one undo takes the cut back',
|
||
JSON.stringify((await model()).items.find((item) => item.id === riff.id)) === JSON.stringify(movedRiff),
|
||
);
|
||
|
||
// 65e-ii. Its left end pulls it out a bar earlier, repeating what it plays; the sound's right end
|
||
// snaps to the end of its second round, not to the bar; Ctrl+V lands under the pointer.
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`);
|
||
to = await json(`__gm.song.laneAt(${BAR}, 2)`);
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
await sleep(200);
|
||
const pulledOut = (await model()).items.find((item) => item.id === riff.id);
|
||
await press('z', { ctrl: true });
|
||
const round = seconds * 960;
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`);
|
||
to = await json(`__gm.song.laneAt(${Math.round(2.1 * round)}, 0)`);
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
await sleep(200);
|
||
const twoRounds = (await model()).items.find((item) => item.id === clip.id);
|
||
await press('z', { ctrl: true });
|
||
// The sound is picked from its drag; a click on the pattern here would make 65f's a double-click.
|
||
await press('c', { ctrl: true });
|
||
p = await json(`__gm.song.laneAt(${6 * BAR + 500}, 1)`);
|
||
await mouse('mouseMoved', p.x, p.y);
|
||
await press('v', { ctrl: true });
|
||
const pastedItem = (await model()).items.find((item) => item.id !== riff.id && item.id !== clip.id);
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'the left end pulls an item out earlier, the right end snaps to the end of a round, and Ctrl+V lands under the pointer',
|
||
pulledOut?.tick === BAR &&
|
||
pulledOut.length === 4 * BAR &&
|
||
pulledOut.offset === 0 &&
|
||
twoRounds?.length === Math.floor(2 * round + 1e-6) &&
|
||
pastedItem?.tick === 6 * BAR &&
|
||
pastedItem.track === tracks[1] &&
|
||
(await model()).items.length === 2,
|
||
JSON.stringify({ pulledOut, round, twoRounds, pastedItem }),
|
||
);
|
||
|
||
// 65f. Ctrl+D puts a copy right behind it; a click on the ruler and Ctrl+E split the copy there.
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`);
|
||
await click(p.x, p.y);
|
||
await press('d', { ctrl: true });
|
||
items = (await model()).items;
|
||
const copy = items.find((item) => item.id !== riff.id && item.kind === 'pattern');
|
||
check(
|
||
'Ctrl+D puts a copy right behind the item on its track, picked',
|
||
items.length === 3 &&
|
||
copy?.tick === 5 * BAR &&
|
||
copy.length === 3 * BAR &&
|
||
copy.track === tracks[2] &&
|
||
(await json('__gm.song.picked()')).join() === copy.id,
|
||
JSON.stringify(items),
|
||
);
|
||
p = await json('__gm.song.fitAt()');
|
||
await click(p.x, p.y);
|
||
// Three quarters into a bar: the start goes to the nearest bar line, the next one, not back.
|
||
p = await json(`__gm.song.rulerAt(${5 * BAR + 1500})`);
|
||
await click(p.x, p.y);
|
||
await press('e', { ctrl: true });
|
||
items = (await model()).items;
|
||
const parts = items.filter((item) => item.tick >= 5 * BAR).sort((a, b) => a.tick - b.tick);
|
||
check(
|
||
'a click on the ruler sets the start on the nearest bar line, and Ctrl+E splits the picked item there',
|
||
(await json('__gm.song.view()')).start === 6 * BAR &&
|
||
parts.length === 2 &&
|
||
parts[0].length === BAR &&
|
||
parts[1].tick === 6 * BAR &&
|
||
parts[1].length === 2 * BAR &&
|
||
parts[1].offset === BAR &&
|
||
(await json('__gm.song.picked()')).join() === parts[1].id,
|
||
JSON.stringify(parts),
|
||
);
|
||
await press('Delete');
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(parts[0].id)}, 'body')`);
|
||
await click(p.x, p.y, 'right');
|
||
await sleep(150);
|
||
const menuDelete = await json("__gm.locate('Delete', true)");
|
||
const beforeMenu = (await model()).items.length;
|
||
if (menuDelete) await click(menuDelete.x, menuDelete.y);
|
||
await sleep(150);
|
||
items = (await model()).items;
|
||
check(
|
||
'Delete takes an item away, and a right-click opens a menu whose "Delete" does',
|
||
beforeMenu === 3 &&
|
||
Boolean(menuDelete) &&
|
||
items.length === 2 &&
|
||
items.every((item) => item.id === riff.id || item.id === clip.id),
|
||
JSON.stringify({ beforeMenu, menuDelete, items }),
|
||
);
|
||
|
||
// 65f-ii. A click on nothing picks the song; a sound item turns up to +12 dB in the inspector, which
|
||
// has no delete button any more. An end of an item under the pointer shows it can be dragged,
|
||
// and an item longer than what it plays marks where it starts over.
|
||
p = await json('__gm.song.laneAt(300, 3)');
|
||
await click(p.x, p.y);
|
||
const emptyPick = await json('__gm.state.selection');
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
const volumeField = await json("__gm.inspectorField('Volume (dB)')");
|
||
await click(volumeField.x, volumeField.y);
|
||
await t.typeText('30');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const loud = (await model()).items.find((item) => item.id === clip.id);
|
||
const deleteButtons = await json("[__gm.locate('Delete item'), __gm.locate('Delete items')]");
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`);
|
||
await mouse('mouseMoved', p.x - 1, p.y);
|
||
await mouse('mouseMoved', p.x, p.y);
|
||
const onEnd = await json('__gm.song.view()');
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
|
||
await mouse('mouseMoved', p.x, p.y);
|
||
const onBody = await json('__gm.song.view()');
|
||
const seams = (await json('__gm.song.items()')).find((item) => item.id === riff.id)?.seams;
|
||
check(
|
||
'a click on nothing picks the song, a sound item goes to +12 dB, an end lights up under the pointer, and a long item marks its rounds',
|
||
emptyPick?.kind === 'asset' &&
|
||
emptyPick.assetId === id &&
|
||
loud?.volume === 12 &&
|
||
deleteButtons.every((b) => b === null) &&
|
||
onEnd.hover?.id === clip.id &&
|
||
onEnd.hover.part === 'end' &&
|
||
onEnd.cursor === 'ew-resize' &&
|
||
onBody.hover?.part === 'body' &&
|
||
onBody.cursor === 'default' &&
|
||
seams?.join() === [3 * BAR, 4 * BAR].join(),
|
||
JSON.stringify({ emptyPick, loud, deleteButtons, onEnd, onBody, seams }),
|
||
);
|
||
await shot('01b-hover');
|
||
|
||
// 65f-iii. The wheel zooms the tracks over their heads and the time over the lanes; a drag on the
|
||
// strip above the ruler sets the loop, Ctrl+Z takes it back.
|
||
const unzoomed = await json('__gm.song.view()');
|
||
p = await json("__gm.song.headAt(0, 'name')");
|
||
await t.wheel(p.x, p.y, -200);
|
||
const taller = await json('__gm.song.view()');
|
||
p = await json(`__gm.song.laneAt(${2 * BAR}, 0)`);
|
||
await t.wheel(p.x, p.y, -200);
|
||
const wider = await json('__gm.song.view()');
|
||
p = await json("__gm.song.headAt(0, 'name')");
|
||
await t.wheel(p.x, p.y, 200);
|
||
p = await json('__gm.song.fitAt()');
|
||
await click(p.x, p.y);
|
||
p = await json(`__gm.song.loopAt(${BAR})`);
|
||
to = await json(`__gm.song.loopAt(${3 * BAR})`);
|
||
await drag(p.x, p.y, to.x, to.y);
|
||
const songLoop = (await model()).loop;
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'the wheel zooms the tracks on their heads and the time on the lanes, and the strip above the ruler sets the loop',
|
||
taller.trackHeight > unzoomed.trackHeight &&
|
||
taller.pxPerTick === unzoomed.pxPerTick &&
|
||
wider.pxPerTick > taller.pxPerTick &&
|
||
wider.trackHeight === taller.trackHeight &&
|
||
songLoop?.start === BAR &&
|
||
songLoop.end === 3 * BAR &&
|
||
(await model()).loop === null,
|
||
JSON.stringify({ unzoomed, taller, wider, songLoop }),
|
||
);
|
||
|
||
// 65g. The heads: mute and solo toggle, the volume slider sets decibels, the menu adds a track.
|
||
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
|
||
await click(...Object.values(await json("__gm.song.headAt(2, 'solo')")));
|
||
p = await json("__gm.song.headAt(2, 'volume', 0.5)");
|
||
to = await json("__gm.song.headAt(2, 'volume', 0)");
|
||
await drag(p.x, p.y, to.x + 1, to.y);
|
||
await sleep(150);
|
||
await addTrackBelowLast();
|
||
const heads = await model();
|
||
check(
|
||
'a head mutes, solos and turns down its track, its menu adds a fifth below and picks it',
|
||
heads.tracks.length === 5 &&
|
||
heads.tracks[0].mute &&
|
||
heads.tracks[2].solo &&
|
||
heads.tracks[2].volume < -55 &&
|
||
(await json('__gm.song.pickedTrack()')) === heads.tracks[4].id &&
|
||
(await json('__gm.song.info()')) === '5 tracks · 2 items',
|
||
JSON.stringify(heads.tracks),
|
||
);
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.tracks[2].pan = 0.5;`);
|
||
await sleep(100);
|
||
p = await json("__gm.song.headAt(2, 'volume', 0.2)");
|
||
await doubleClick(p.x, p.y);
|
||
p = await json("__gm.song.headAt(2, 'pan', 0.9)");
|
||
await doubleClick(p.x, p.y);
|
||
await sleep(150);
|
||
const reset = (await model()).tracks[2];
|
||
check(
|
||
'a double-click on a slider sets it back to 0: volume to 0 dB, pan to the middle',
|
||
reset.volume === 0 && reset.pan === 0,
|
||
JSON.stringify(reset),
|
||
);
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.tracks[2].volume = -6;
|
||
song.tracks[2].solo = false;
|
||
song.tracks[0].mute = false;`);
|
||
|
||
// 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a
|
||
// clip round and round, each on the bus of its track.
|
||
p = await json('__gm.song.rulerAt(0)');
|
||
await click(p.x, p.y);
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.items.find((i) => i.kind === 'sound').length = ${BAR};`);
|
||
await press('Space');
|
||
const played = await waitFor(
|
||
() => json('{ planned: __gm.song.planned(), clips: __gm.song.clips(), playing: __gm.song.playing() }'),
|
||
(s) => s.planned.length >= 2 && s.clips.length >= 1,
|
||
60,
|
||
);
|
||
const busOf = (track) => `song:${id}:${track}`;
|
||
/** Where a track's own items go in, on to its bus (#350). */
|
||
const itemsOf = (track) => `${busOf(track)}:#items`;
|
||
const firstClip = played.clips[0];
|
||
const notes = played.planned.slice(0, 2);
|
||
check(
|
||
'Space plays the song: the clip at once, the pattern from bar 3 with its notes a second apart, each on its track',
|
||
played.playing &&
|
||
Boolean(firstClip) &&
|
||
firstClip.sound === SOUND &&
|
||
firstClip.bus === itemsOf(tracks[0]) &&
|
||
Math.abs(firstClip.duration - 2) < 1e-9 &&
|
||
notes.length === 2 &&
|
||
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === itemsOf(tracks[2]) && n.tempo === 120) &&
|
||
Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 &&
|
||
Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 &&
|
||
notes[0].note === 60,
|
||
JSON.stringify(played),
|
||
);
|
||
const buses = await json('__gm.song.buses()');
|
||
const bus = (track) => buses.find((b) => b.name === busOf(track));
|
||
check(
|
||
'each track is set on its bus: the turned-down one at -6 dB, the others at unity',
|
||
Math.abs(bus(tracks[2])?.gain - 10 ** (-6 / 20)) < 1e-6 &&
|
||
bus(tracks[0])?.gain === 1 &&
|
||
bus(tracks[1])?.gain === 1,
|
||
JSON.stringify(buses),
|
||
);
|
||
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
|
||
// Mute closes every send of the track (#350); its own level stays as it is set.
|
||
const closed = (list) => {
|
||
const sends = list.find((b) => b.name === busOf(tracks[0]))?.sends ?? [];
|
||
return sends.length > 0 && sends.every((send) => send.gain === 0);
|
||
};
|
||
const muted = await waitFor(() => json('__gm.song.buses()'), closed);
|
||
check(
|
||
'muting a track while it plays closes its sends at once',
|
||
closed(muted) && (await json('__gm.song.playing()')),
|
||
JSON.stringify(muted),
|
||
);
|
||
await shot('02-playing');
|
||
|
||
// 65i. A pattern played in its tab takes over; a double-click on a pattern item opens it there.
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(PATTERN)})`);
|
||
await sleep(200);
|
||
p = await json('__gm.pattern.playAt()');
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
const swapped = await json('{ song: __gm.song.playing(), pattern: __gm.pattern.playing() }');
|
||
await click(p.x, p.y);
|
||
check(
|
||
'a pattern started in its tab stops the song',
|
||
swapped.pattern && !swapped.song,
|
||
JSON.stringify(swapped),
|
||
);
|
||
// The riff a tone up for four bars on the second track: what its ghost notes (#339) show.
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.items.push({ ...song.items.find((i) => i.kind === 'pattern'), id: 'sg65-tone', track: song.tracks[1].id, tick: 0, length: ${4 * BAR}, offset: 0, transpose: 2 });`);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`);
|
||
await doubleClick(p.x, p.y);
|
||
await sleep(200);
|
||
const opened = await json('{ tab: __gm.tab(), pattern: __gm.pattern.current() }');
|
||
check(
|
||
'a double-click on a pattern item opens the pattern in its tab',
|
||
opened.tab === 'pattern' && opened.pattern === PATTERN,
|
||
JSON.stringify(opened),
|
||
);
|
||
const songNow = {
|
||
...(await model()),
|
||
name: await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`),
|
||
};
|
||
const riffNow = songNow.items.find((i) => i.id === riff.id);
|
||
const trackName = (trackId) => songNow.tracks.find((tr) => tr.id === trackId).name;
|
||
const ghosts = await json('__gm.pattern.ghosts()');
|
||
const tone = ghosts.notes.filter((n) => n.track === trackName(songNow.tracks[1].id));
|
||
const toneTicks = tone.map((n) => n.tick + ghosts.origin);
|
||
check(
|
||
'the pattern opened from the song shows the other track faint: each note where it sounds, a tone up, on by default',
|
||
ghosts.toggled &&
|
||
ghosts.enabled &&
|
||
tone.length === 8 &&
|
||
toneTicks.every((tick, i) => tick === Math.floor(i / 2) * BAR + (i % 2) * 960) &&
|
||
tone.every((n, i) => n.note === (i % 2 ? 64 : 60) + 2 - riffNow.transpose && n.pattern === 'Riff 65') &&
|
||
(ghosts.origin - riffNow.tick + (riffNow.offset % BAR)) % BAR === 0 &&
|
||
ghosts.source ===
|
||
`${songNow.name} · ${trackName(riffNow.track)} · bar ${Math.floor(Math.max(ghosts.origin, riffNow.tick) / BAR) + 1}`,
|
||
JSON.stringify({ ghosts, riff: riffNow }),
|
||
);
|
||
const inside = tone.find((n) => n.tick >= 0 && n.tick < BAR);
|
||
p = await json(`__gm.pattern.gridAt(${inside.tick + 100}, ${inside.note})`);
|
||
await mouse('mouseMoved', p.x - 2, p.y);
|
||
await mouse('mouseMoved', p.x, p.y);
|
||
const sign = await waitFor(
|
||
() => json('__gm.tooltip()'),
|
||
(s) => s !== null,
|
||
);
|
||
check(
|
||
'resting on a ghost note tells its track and its pattern',
|
||
sign?.text === `${trackName(songNow.tracks[1].id)} · Riff 65`,
|
||
JSON.stringify(sign),
|
||
);
|
||
await shot('03-ghosts');
|
||
await click(ghosts.x, ghosts.y);
|
||
await sleep(150);
|
||
const hidden = await json('__gm.pattern.ghosts()');
|
||
await click(ghosts.x, ghosts.y);
|
||
await sleep(150);
|
||
check(
|
||
'Ghosts hides them and shows them again',
|
||
!hidden.toggled &&
|
||
hidden.notes.length === 0 &&
|
||
(await json('__gm.pattern.ghosts()')).notes.length === ghosts.notes.length,
|
||
JSON.stringify({ hidden: hidden.notes.length, toggled: hidden.toggled }),
|
||
);
|
||
|
||
// 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the
|
||
// loop of its ruler, each track on a bus inside a master of the playing. The pause of the
|
||
// toolbar holds the clock; S fires Stop song, which fades it out, then it is gone.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.tracks.forEach((track, i) => Object.assign(track, { mute: false, solo: false, volume: i === 1 ? -6 : 0, pan: i === 1 ? -0.5 : 0 }));
|
||
const base = song.items[0];
|
||
song.items = [
|
||
{ ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 },
|
||
{ ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 },
|
||
];
|
||
song.loop = { start: ${BAR}, end: ${2 * BAR} };`);
|
||
await showTab('scene');
|
||
const rig = await scaffoldBehavior(t, {
|
||
assetId: 'sg65-logic',
|
||
name: 'Music 65',
|
||
boxId: 'sg65-box',
|
||
logicId: 'sg65-brain',
|
||
y: LANES.sound,
|
||
graph: {
|
||
nodes: [
|
||
node('sg65-start', 'OnStart', 0, 0),
|
||
node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }),
|
||
node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }),
|
||
node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }),
|
||
],
|
||
edges: [
|
||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||
edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'),
|
||
],
|
||
},
|
||
});
|
||
const playbacks = () => json('__gm.sound.playbacks()');
|
||
await play();
|
||
const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60);
|
||
const [run] = game;
|
||
const master = `play:${run?.id}`;
|
||
const gameNotes = run?.planned ?? [];
|
||
const gameClip = run?.clips[0];
|
||
check(
|
||
'Play song plays the clip once as the intro, then the pattern round the loop, 2 s a round, on their tracks',
|
||
game.length === 1 &&
|
||
run.name === `song:${id}` &&
|
||
run.owner !== '' &&
|
||
run.clips.length === 1 &&
|
||
gameClip?.sound === SOUND &&
|
||
gameClip.bus === itemsOf(tracks[0]) &&
|
||
gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) &&
|
||
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
|
||
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
|
||
Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6,
|
||
JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gameNotes }),
|
||
);
|
||
const gameBuses = await json('__gm.song.buses()');
|
||
const gameBus = (name) => gameBuses.find((b) => b.name === name);
|
||
check(
|
||
'the playing has a master at its volume, the tracks inside it as they are set',
|
||
gameBus(master)?.gain === 0.8 &&
|
||
gameBus(`${master}:${busOf(tracks[0])}`)?.gain === 1 &&
|
||
Math.abs(gameBus(`${master}:${busOf(tracks[1])}`)?.gain - 10 ** (-6 / 20)) < 1e-6 &&
|
||
gameBus(`${master}:${busOf(tracks[1])}`)?.pan === -0.5,
|
||
JSON.stringify(gameBuses),
|
||
);
|
||
await clickToolbar('pause');
|
||
await sleep(400);
|
||
const heldClock = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }');
|
||
await sleep(300);
|
||
const heldLater = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }');
|
||
await clickToolbar('pause');
|
||
await sleep(200);
|
||
const resumed = await json('__gm.sound.clockState()');
|
||
check(
|
||
'the pause of the toolbar holds the clock while the song plays, and resuming lets it run',
|
||
heldClock.state === 'suspended' &&
|
||
heldLater.state === 'suspended' &&
|
||
heldLater.at === heldClock.at &&
|
||
resumed === 'running',
|
||
JSON.stringify({ heldClock, heldLater, resumed }),
|
||
);
|
||
await press('s');
|
||
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
|
||
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
|
||
await sleep(100);
|
||
const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master));
|
||
await stop();
|
||
check(
|
||
'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone',
|
||
fading.length === 1 &&
|
||
fading[0].fading !== null &&
|
||
fading[0].playing &&
|
||
fadedOut.length === 0 &&
|
||
leftBuses.length === 0,
|
||
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
|
||
);
|
||
const left = await rig.remove();
|
||
|
||
// 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a
|
||
// track of its own under the one clicked, Space keeps it – WAV up, sound, track and item one
|
||
// undo step –, Esc throws the next one away.
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
const before = await model();
|
||
p = await json("__gm.song.headAt(0, 'name')");
|
||
await click(p.x, p.y);
|
||
p = await json(`__gm.song.rulerAt(${BAR})`);
|
||
await click(p.x, p.y);
|
||
p = await json('__gm.song.recordAt()');
|
||
await click(p.x, p.y);
|
||
const growing = await waitFor(
|
||
() =>
|
||
json('{ view: __gm.song.view(), recording: __gm.song.recording(), button: __gm.song.recordAt().text }'),
|
||
(s) => s.view.take?.peaks > 120,
|
||
60,
|
||
);
|
||
await shot('03-recording');
|
||
check(
|
||
'Record plays from the cursor and grows the take, red, on a new track under the one clicked',
|
||
growing.recording &&
|
||
growing.button === 'Stop' &&
|
||
growing.view.playhead !== null &&
|
||
growing.view.take?.index === 1 &&
|
||
growing.view.take.tick === BAR &&
|
||
growing.view.take.length > 0 &&
|
||
!growing.view.take.uploading,
|
||
JSON.stringify(growing),
|
||
);
|
||
await press('Space');
|
||
const kept = await waitFor(
|
||
() => model(),
|
||
(m) => m.tracks.length === before.tracks.length + 1,
|
||
60,
|
||
);
|
||
const takeTrack = kept.tracks[1];
|
||
const takeItem = kept.items.find((i) => i.track === takeTrack?.id);
|
||
const takeId = JSON.stringify(takeItem?.asset ?? '');
|
||
const takeAsset = await json(
|
||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${takeId}); return a ? { name: a.name, file: a.file, type: a.type, owner: a.owner } : null; })()`,
|
||
);
|
||
const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`);
|
||
const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0;
|
||
check(
|
||
'Space keeps it: a WAV sound of the song\'s own, "Take 1", on the track "Recording", from the cursor, as long as it is',
|
||
kept.tracks[0]?.id === before.tracks[0]?.id &&
|
||
takeTrack?.name === 'Recording' &&
|
||
takeItem?.kind === 'sound' &&
|
||
takeItem.tick === BAR &&
|
||
takeAsset?.type === 'sound' &&
|
||
takeAsset.name === 'Take 1' &&
|
||
takeAsset.owner === id &&
|
||
/^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(takeAsset.file) &&
|
||
takeSeconds > 1 &&
|
||
Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 &&
|
||
!(await json('__gm.song.recording()')) &&
|
||
(await json('__gm.song.view()')).take === null,
|
||
JSON.stringify({ kept, takeAsset, takeSeconds }),
|
||
);
|
||
// #355: the take is the song's own – no row in the asset panel, a mark on its item, its song in the tab title.
|
||
const takeRow = await json(`__gm.assetRow(${takeId})`);
|
||
const takeLabel = (await json('__gm.song.items()')).find((i) => i.id === takeItem?.id)?.label;
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(takeItem?.id ?? '')}, 'body')`);
|
||
await doubleClick(p.x, p.y);
|
||
await sleep(200);
|
||
const takeTab = await json(`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${takeId}] }`);
|
||
check(
|
||
'the take lives in the song: no row in the asset panel, • on its item, a double-click opens "Song › Take 1"',
|
||
takeRow === null &&
|
||
takeLabel === '• Take 1' &&
|
||
takeTab.tab === 'sound' &&
|
||
takeTab.title === `${songName} › Take 1`,
|
||
JSON.stringify({ takeRow, takeLabel, takeTab }),
|
||
);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
await press('z', { ctrl: true });
|
||
const undone = await model();
|
||
check(
|
||
'one Ctrl+Z takes sound, track and item back',
|
||
undone.tracks.length === before.tracks.length &&
|
||
undone.items.length === before.items.length &&
|
||
(await json(
|
||
`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(takeItem?.asset ?? '')})`,
|
||
)) === false,
|
||
JSON.stringify(undone),
|
||
);
|
||
const assetCount = await json('__gm.state.project.assets.length');
|
||
p = await json('__gm.song.recordAt()');
|
||
await click(p.x, p.y);
|
||
await waitFor(
|
||
() => json('__gm.song.view()'),
|
||
(v) => v.take?.peaks > 20,
|
||
60,
|
||
);
|
||
await press('Escape');
|
||
await sleep(300);
|
||
const thrown = await json(
|
||
'{ recording: __gm.song.recording(), take: __gm.song.view().take, playing: __gm.song.playing(), assets: __gm.state.project.assets.length }',
|
||
);
|
||
check(
|
||
'Esc while recording throws the take away: nothing is added, playing stops',
|
||
!thrown.recording &&
|
||
thrown.take === null &&
|
||
!thrown.playing &&
|
||
thrown.assets === assetCount &&
|
||
(await model()).tracks.length === before.tracks.length,
|
||
JSON.stringify(thrown),
|
||
);
|
||
|
||
// 65m. A sound FX on a track (#346). Offline, the shifter a Pitch on a bus becomes moves a tone an
|
||
// octave and is late by exactly a frame. In the tab, the FX box of a head picks the FX: all of
|
||
// the track goes through one chain on its bus, the pattern's notes start a frame earlier and
|
||
// are heard where they were planned, as the clip of the other track is. Stop lets it go.
|
||
const shifted = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (pitch, input) => {
|
||
const context = new OfflineAudioContext(2, RATE, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const buffer = context.createBuffer(1, RATE, RATE);
|
||
buffer.copyToChannel(input, 0);
|
||
const source = context.createBufferSource();
|
||
source.buffer = buffer;
|
||
const shifter = new AudioWorkletNode(context, 'pitch-shifter', {
|
||
numberOfInputs: 1,
|
||
outputChannelCount: [2],
|
||
channelCount: 2,
|
||
channelCountMode: 'explicit',
|
||
parameterData: { pitch },
|
||
});
|
||
source.connect(shifter).connect(context.destination);
|
||
source.start();
|
||
return (await context.startRendering()).getChannelData(1);
|
||
};
|
||
const tone = new Float32Array(RATE).map((_, i) => Math.sin((2 * Math.PI * 1000 * i) / RATE));
|
||
const up = await render(12, tone);
|
||
let rises = 0;
|
||
for (let i = RATE / 4 + 1; i < (RATE * 3) / 4; i++) if (up[i - 1] < 0 && up[i] >= 0) rises++;
|
||
const click = new Float32Array(RATE);
|
||
click[4800] = 1;
|
||
const still = await render(0, click);
|
||
let peak = 0;
|
||
for (let i = 1; i < RATE; i++) if (Math.abs(still[i]) > Math.abs(still[peak])) peak = i;
|
||
return { hz: rises * 2, late: peak - 4800, height: still[peak] };
|
||
})()`);
|
||
check(
|
||
'the shifter of a bus moves a 1 kHz tone up to 2 kHz, and at 0 semitones is the sound exactly 2048 samples later',
|
||
Math.abs(shifted.hz - 2000) <= 20 && shifted.late === 2048 && Math.abs(shifted.height - 1) < 1e-4,
|
||
JSON.stringify(shifted),
|
||
);
|
||
await mutate(`
|
||
__gm.state.project.assets.push({
|
||
id: 'sg65-fx',
|
||
type: 'soundfx',
|
||
name: 'Octave 65',
|
||
props: {},
|
||
graph: {
|
||
nodes: ${JSON.stringify([
|
||
node('sg65-fin', 'SoundFxInput', 0, 0),
|
||
node('sg65-fpitch', 'SoundFxPitch', 200, 0, { semitones: 12 }),
|
||
node('sg65-fout', 'SoundFxOutput', 400, 0),
|
||
])},
|
||
edges: ${JSON.stringify([
|
||
edge('sg65-fe1', 'sg65-fin', 'out', 'sg65-fpitch', 'in'),
|
||
edge('sg65-fe2', 'sg65-fpitch', 'out', 'sg65-fout', 'in'),
|
||
])},
|
||
},
|
||
});`);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
p = await json("__gm.song.headAt(1, 'fx')");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
await clickText('Octave 65');
|
||
await sleep(150);
|
||
const fxTracks = (await model()).tracks;
|
||
await shot('04-track-fx');
|
||
check(
|
||
"the FX box of a track's head picks a sound FX for that track",
|
||
fxTracks[1]?.fx === 'sg65-fx' && fxTracks.every((track, i) => i === 1 || track.fx === ''),
|
||
JSON.stringify(fxTracks.map((track) => track.fx)),
|
||
);
|
||
p = await json('__gm.song.rulerAt(0)');
|
||
await click(p.x, p.y);
|
||
await press('Space');
|
||
const throughFx = await waitFor(
|
||
() => json('{ planned: __gm.song.planned(), clips: __gm.song.clips() }'),
|
||
(s) => s.planned.length >= 1 && s.clips.length >= 1,
|
||
60,
|
||
);
|
||
const rate = await json('__gm.sound.sampleRate()');
|
||
const frame = 2048 / rate;
|
||
const fxBus = (await json('__gm.song.buses()')).find((b) => b.name === busOf(tracks[1]));
|
||
const [fxNote] = throughFx.planned;
|
||
const [plainClip] = throughFx.clips;
|
||
const noteStart = await json(`__gm.sound.startedAt(${fxNote?.voice ?? 0})`);
|
||
const clipStart = await json(`__gm.sound.startedAt(${plainClip?.voice ?? 0})`);
|
||
check(
|
||
'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time',
|
||
fxBus?.fx === true &&
|
||
Math.abs(fxBus.latency - frame) < 1e-9 &&
|
||
fxNote?.bus === itemsOf(tracks[1]) &&
|
||
plainClip?.bus === itemsOf(tracks[0]) &&
|
||
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
|
||
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
|
||
Math.abs(clipStart - plainClip.at) < 1e-6,
|
||
JSON.stringify({ rate, fxBus, fxNote, plainClip, noteStart, clipStart }),
|
||
);
|
||
await press('Space');
|
||
const released = await waitFor(
|
||
() => json('__gm.song.buses()'),
|
||
(list) => !list.some((b) => b.name === busOf(tracks[1])),
|
||
);
|
||
check(
|
||
'stopping lets the buses of the tab go, the chain on them too',
|
||
!(await json('__gm.song.playing()')) && !released.some((b) => b.name.startsWith(`song:${id}:`)),
|
||
JSON.stringify(released),
|
||
);
|
||
|
||
// 65l. Routing (#350): the switch in the header shows the song as a graph, a node per track and
|
||
// the output, every track linked to it. A drag from one track's out into another's in sends
|
||
// it there, one back would close a loop and is refused, Delete takes a link but not a track.
|
||
// A track linked nowhere says it is not heard. Volume and Mode of a link in the inspector
|
||
// are what its send is set to when the song plays.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||
song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
const magnetOnTimeline = await json('__gm.song.routing.snapButton()');
|
||
p = await json("__gm.song.viewAt('routing')");
|
||
await click(p.x, p.y);
|
||
await sleep(300);
|
||
// The magnet is the routing's, as in every graph tab: off at first, greyed out on the timeline.
|
||
const magnet = await json('__gm.song.routing.snapButton()');
|
||
const snapOff = await json('__gm.song.routing.snapping()');
|
||
await click(magnet.x, magnet.y);
|
||
const snapOn = await json('__gm.song.routing.snapping()');
|
||
const lit = (await json('__gm.song.routing.snapButton()')).toggled;
|
||
await click(magnet.x, magnet.y);
|
||
check(
|
||
'the magnet in the header switches snapping in the routing on and off',
|
||
!magnetOnTimeline.enabled &&
|
||
magnet.enabled &&
|
||
snapOff === false &&
|
||
snapOn === true &&
|
||
lit &&
|
||
(await json('__gm.song.routing.snapping()')) === false,
|
||
JSON.stringify({ magnetOnTimeline, magnet, snapOff, snapOn, lit }),
|
||
);
|
||
const routingOf = () => json(`__gm.song.routing.graph(${JSON.stringify(id)})`);
|
||
// The tracks as they are now: more than in 65a.
|
||
const ids = (await model()).tracks.map((track) => track.id);
|
||
const linksOf = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
|
||
const opened65 = await json(`(() => {
|
||
const graph = __gm.song.routing.graph(${JSON.stringify(id)});
|
||
return { shown: __gm.song.shown(), graph, labels: graph.nodes.map((n) => __gm.song.routing.label(n.id)) };
|
||
})()`);
|
||
const names = (await model()).tracks.map((track) => track.name);
|
||
await shot('05-routing');
|
||
check(
|
||
'Routing shows a node per track and the output, every track linked to the output',
|
||
opened65.shown === 'routing' &&
|
||
JSON.stringify(opened65.graph.nodes.map((n) => n.id)) === JSON.stringify([...ids, '#output']) &&
|
||
JSON.stringify(opened65.labels) === JSON.stringify([...names, 'Output']) &&
|
||
JSON.stringify(linksOf(opened65.graph)) === JSON.stringify(ids.map((track) => `${track}>#output`)),
|
||
JSON.stringify(opened65),
|
||
);
|
||
const port = (nodeId, key, isOutput) =>
|
||
json(`__gm.song.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
|
||
let fromPort = await port(ids[0], 'out', true);
|
||
let toPort = await port(ids[1], 'in', false);
|
||
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
|
||
await sleep(200);
|
||
const linked = await routingOf();
|
||
fromPort = await port(ids[1], 'out', true);
|
||
toPort = await port(ids[0], 'in', false);
|
||
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
|
||
await sleep(200);
|
||
const refused = await routingOf();
|
||
check(
|
||
'a drag from a track into another links them, one back would close a loop and is refused',
|
||
linksOf(linked).includes(`${ids[0]}>${ids[1]}`) &&
|
||
linked.edges.length === ids.length + 1 &&
|
||
JSON.stringify(refused.edges) === JSON.stringify(linked.edges),
|
||
JSON.stringify({ linked: linksOf(linked), refused: linksOf(refused) }),
|
||
);
|
||
const toOutput = (graph, track) =>
|
||
graph.edges.find((e) => e.from.node === track && e.to.node === '#output');
|
||
for (const track of [ids[0], ids[2]]) {
|
||
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(toOutput(await routingOf(), track).id)})`);
|
||
await click(p.x, p.y);
|
||
await press('Delete');
|
||
await sleep(150);
|
||
}
|
||
p = await json(`__gm.song.routing.nodeAt(${JSON.stringify(ids[3])})`);
|
||
await click(p.x, p.y);
|
||
await press('Delete');
|
||
await sleep(150);
|
||
const trimmed = await routingOf();
|
||
const nowhere = await json(`__gm.song.routing.problem(${JSON.stringify(ids[2])})`);
|
||
check(
|
||
'Delete takes a link but never a track, and a track linked nowhere says it is not heard',
|
||
!toOutput(trimmed, ids[0]) &&
|
||
!toOutput(trimmed, ids[2]) &&
|
||
trimmed.nodes.length === ids.length + 1 &&
|
||
String(nowhere).startsWith('Goes nowhere') &&
|
||
(await json(`__gm.song.routing.problem(${JSON.stringify(ids[0])})`)) === null,
|
||
JSON.stringify({ links: linksOf(trimmed), nodes: trimmed.nodes.length, nowhere }),
|
||
);
|
||
const send = trimmed.edges.find((e) => e.from.node === ids[0] && e.to.node === ids[1]);
|
||
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(send.id)})`);
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
p = await json("__gm.inspectorField('Volume')");
|
||
await click(p.x, p.y);
|
||
await t.typeText('-6');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
p = await json("__gm.inspectorField('Mode')");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
await clickText('Pre volume (Pre FX)');
|
||
await sleep(150);
|
||
const set = (await routingOf()).edges.find((e) => e.id === send.id);
|
||
await shot('06-send');
|
||
check(
|
||
'Volume and Mode of a link in the inspector set its send',
|
||
set?.send?.volume === -6 && set.send.tap === 'preFx',
|
||
JSON.stringify(set),
|
||
);
|
||
p = await json('__gm.song.playAt()');
|
||
await click(p.x, p.y);
|
||
const sending = await waitFor(
|
||
() => json('__gm.song.buses()'),
|
||
(list) => list.some((b) => b.name === busOf(ids[0]) && b.sends),
|
||
);
|
||
const sends = (track) => sending.find((b) => b.name === busOf(track))?.sends;
|
||
check(
|
||
'playing, each track goes where its links say: through the send set, nowhere for the one without',
|
||
sends(ids[0])?.length === 1 &&
|
||
sends(ids[0])[0].to === busOf(ids[1]) &&
|
||
sends(ids[0])[0].tap === 'preFx' &&
|
||
Math.abs(sends(ids[0])[0].gain - 10 ** (-6 / 20)) < 1e-6 &&
|
||
sends(ids[1])?.length === 1 &&
|
||
sends(ids[2])?.length === 0,
|
||
JSON.stringify(sending.filter((b) => b.name.startsWith(`song:${id}:`))),
|
||
);
|
||
await click(p.x, p.y);
|
||
p = await json("__gm.song.viewAt('timeline')");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
|
||
// 65n. A sound FX on the song and on its items (#352). An item's Sound FX and a pattern item's
|
||
// Instrument are the ones of its asset while empty; None is a pick of its own. The song
|
||
// plays through its own on a bus of the tab, into the bus of the song. A double-click on a
|
||
// sound item opens the sound.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
// The items as they are now: those of 65c went with the takes of 65l.
|
||
const soundItem = (await model()).items.find((item) => item.kind === 'sound');
|
||
const patternItem = (await model()).items.find((item) => item.kind === 'pattern');
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(soundItem.id)}, 'body')`);
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
p = await json("__gm.inspectorField('Sound FX', true)");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
const fxRows = await json('__gm.menuRows()');
|
||
await clickText('None');
|
||
await sleep(150);
|
||
const clipFx = (await model()).items.find((item) => item.id === soundItem.id)?.fx;
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(patternItem.id)}, 'body')`);
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
p = await json("__gm.inspectorField('Instrument', true)");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
const instrumentRows = await json('__gm.menuRows()');
|
||
await press('Escape');
|
||
check(
|
||
"an item's Sound FX offers From the asset, None and the sound FX, its Instrument From the asset",
|
||
fxRows?.[0] === 'From the asset' &&
|
||
fxRows[1] === 'None' &&
|
||
fxRows.includes('Octave 65 (soundfx)') &&
|
||
clipFx === '#none' &&
|
||
instrumentRows?.[0] === 'From the asset',
|
||
JSON.stringify({ fxRows, clipFx, instrumentRows }),
|
||
);
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`);
|
||
await sleep(150);
|
||
p = await json("__gm.inspectorField('Sound FX', true)");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
await clickText('Octave 65 (soundfx)');
|
||
await sleep(150);
|
||
const songFx = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).props.fx`);
|
||
p = await json('__gm.song.playAt()');
|
||
await click(p.x, p.y);
|
||
const throughSong = await waitFor(
|
||
() => json('__gm.song.buses()'),
|
||
(list) => list.some((b) => b.name.startsWith('music:') && b.fx),
|
||
);
|
||
const songFxBus = throughSong.find((b) => b.name.startsWith('music:') && b.fx);
|
||
await click(p.x, p.y);
|
||
check(
|
||
'the Sound FX of the song is on a bus of the tab into the bus of the song, the tracks to the output go there',
|
||
songFx === 'sg65-fx' &&
|
||
songFxBus?.into === 'mix' &&
|
||
Math.abs(songFxBus.latency - 2048 / (await json('__gm.sound.sampleRate()'))) < 1e-9 &&
|
||
throughSong.find((b) => b.name === busOf(ids[1]))?.sends?.[0]?.to === songFxBus.name,
|
||
JSON.stringify({ songFx, songFxBus, sends: throughSong.find((b) => b.name === busOf(ids[1]))?.sends }),
|
||
);
|
||
const fxGone = await waitFor(
|
||
() => json('__gm.song.buses()'),
|
||
(list) => !list.some((b) => b.name.startsWith('music:')),
|
||
);
|
||
p = await json(`__gm.song.itemAt(${JSON.stringify(soundItem.id)}, 'body')`);
|
||
await doubleClick(p.x, p.y);
|
||
await sleep(200);
|
||
const openedSound = await json('{ tab: __gm.tab(), sound: __gm.soundTab.current() }');
|
||
check(
|
||
'stopping lets the bus of the sound FX go, and a double-click on a sound item opens the sound',
|
||
!fxGone.some((b) => b.name.startsWith('music:')) &&
|
||
openedSound.tab === 'sound' &&
|
||
openedSound.sound === SOUND,
|
||
JSON.stringify({ fxGone: fxGone.map((b) => b.name), openedSound }),
|
||
);
|
||
|
||
// 65o. A right-click on an empty spot of a track makes a pattern of the song's own (#355): a bar
|
||
// from the grid line before it, with the instrument of the track's pattern, opened in its tab.
|
||
// One Ctrl+Z takes pattern and item back.
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
const spotSong = await model();
|
||
const riffItem = spotSong.items.find((item) => item.id === patternItem.id);
|
||
const spotRow = spotSong.tracks.findIndex((track) => track.id === riffItem.track);
|
||
const onTrack = spotSong.items.filter((item) => item.track === riffItem.track);
|
||
let spotBar = Math.ceil((riffItem.tick + riffItem.length) / BAR);
|
||
while (onTrack.some((item) => item.tick < (spotBar + 1) * BAR && item.tick + item.length > spotBar * BAR))
|
||
spotBar++;
|
||
p = await json(`__gm.song.laneAt(${spotBar * BAR + BAR / 4}, ${spotRow})`);
|
||
await click(p.x, p.y, 'right');
|
||
await sleep(150);
|
||
const spotRows = await json('__gm.menuRows()');
|
||
await clickText('Create pattern');
|
||
await sleep(250);
|
||
const made = await model();
|
||
const madeItem = made.items.find((item) => !spotSong.items.some((other) => other.id === item.id));
|
||
const madeId = JSON.stringify(madeItem?.asset ?? '');
|
||
const madePattern = await json(
|
||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${madeId}); return a ? { name: a.name, owner: a.owner, type: a.type, props: a.props, notes: a.notes.length } : null; })()`,
|
||
);
|
||
const madeTab = await json(
|
||
`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${madeId}], row: __gm.assetRow(${madeId}) }`,
|
||
);
|
||
check(
|
||
"Create pattern on an empty spot: a bar of the song's own from the bar before, Lead 65, opened in its tab",
|
||
spotRows?.includes('Create pattern') &&
|
||
madeItem?.kind === 'pattern' &&
|
||
madeItem.track === riffItem.track &&
|
||
madeItem.tick === spotBar * BAR &&
|
||
madeItem.length === BAR &&
|
||
madePattern?.type === 'pattern' &&
|
||
madePattern.owner === id &&
|
||
madePattern.name === 'Pattern 1' &&
|
||
madePattern.notes === 0 &&
|
||
madePattern.props.bars === 1 &&
|
||
madePattern.props.instrument === 'sg65-ins' &&
|
||
madePattern.props.tempo === 120 &&
|
||
madePattern.props.beats === 4 &&
|
||
madeTab.tab === 'pattern' &&
|
||
madeTab.title === `${songName} › Pattern 1` &&
|
||
madeTab.row === null,
|
||
JSON.stringify({ spotRows, spotBar, madeItem, madePattern, madeTab }),
|
||
);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
await press('z', { ctrl: true });
|
||
await sleep(150);
|
||
check(
|
||
'one Ctrl+Z takes the pattern and its item back',
|
||
(await model()).items.length === spotSong.items.length &&
|
||
(await json(`__gm.state.project.assets.some((a) => a.id === ${madeId})`)) === false,
|
||
JSON.stringify((await model()).items.map((item) => item.id)),
|
||
);
|
||
|
||
// 65p. A sound FX of the track's own (#355), from the menu of its FX button: it lets the sound
|
||
// through, is opened in its tab and named there with a mark. Picking another lets it go, and
|
||
// a Ctrl+Z brings it back.
|
||
const fxTrackBefore = (await model()).tracks[0].fx;
|
||
p = await json("__gm.song.headAt(0, 'fx')");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
const createRows = await json('__gm.menuRows()');
|
||
await clickText('Create sound FX');
|
||
await sleep(250);
|
||
const ownFxId = JSON.stringify((await model()).tracks[0].fx);
|
||
const ownFx = await json(
|
||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${ownFxId}); return a ? { name: a.name, owner: a.owner, type: a.type, nodes: a.graph.nodes.map((n) => n.type), edges: a.graph.edges.length } : null; })()`,
|
||
);
|
||
const ownFxTab = await json(
|
||
`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${ownFxId}], row: __gm.assetRow(${ownFxId}) }`,
|
||
);
|
||
check(
|
||
"Create sound FX on a track's FX button: one of the song's own, Input to Output, opened in its tab",
|
||
createRows?.includes('Create sound FX') &&
|
||
ownFxId !== JSON.stringify(fxTrackBefore) &&
|
||
ownFx?.type === 'soundfx' &&
|
||
ownFx.owner === id &&
|
||
ownFx.name === 'Sound FX 1' &&
|
||
JSON.stringify(ownFx.nodes) === JSON.stringify(['SoundFxInput', 'SoundFxOutput']) &&
|
||
ownFx.edges === 1 &&
|
||
ownFxTab.title === `${songName} › Sound FX 1` &&
|
||
ownFxTab.row === null,
|
||
JSON.stringify({ createRows, ownFx, ownFxTab }),
|
||
);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||
await sleep(200);
|
||
p = await json("__gm.song.headAt(0, 'fx')");
|
||
await click(p.x, p.y);
|
||
await sleep(150);
|
||
const ownRows = await json('__gm.menuRows()');
|
||
await clickText('No sound FX');
|
||
await sleep(150);
|
||
const letGo = await json(`__gm.state.project.assets.some((a) => a.id === ${ownFxId})`);
|
||
await press('z', { ctrl: true });
|
||
await sleep(150);
|
||
const backAgain = await json(
|
||
`{ fx: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).tracks[0].fx, owner: __gm.state.project.assets.find((a) => a.id === ${ownFxId})?.owner }`,
|
||
);
|
||
check(
|
||
'its menu names it with Edit and Make project asset, No sound FX lets it go, Ctrl+Z brings it back',
|
||
ownRows?.includes('• Sound FX 1') &&
|
||
ownRows.includes('Edit sound FX') &&
|
||
ownRows.includes('Make project asset') &&
|
||
!ownRows.includes('Create sound FX') &&
|
||
letGo === false &&
|
||
JSON.stringify(backAgain.fx) === ownFxId &&
|
||
backAgain.owner === id,
|
||
JSON.stringify({ ownRows, letGo, backAgain }),
|
||
);
|
||
|
||
// 65j. The step takes back everything it made.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await mutate(`
|
||
const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}, 'sg65-fx']);
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id) && a.owner !== ${JSON.stringify(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('sg65') || a.type === 'song').length`,
|
||
)) === 0 &&
|
||
left.objects === 0 &&
|
||
left.assets === 0,
|
||
JSON.stringify(left),
|
||
);
|
||
}
|