A bass line is written against the chords it goes with. Until now the piano roll showed only the pattern itself, so writing a part to fit the rest of a song meant switching between the Pattern and the Song tab. - A double-click on a pattern item remembers song, item and the round clicked into for that pattern. The place lives in the session, not in the project, and goes by the item's id, so moving the item moves the ghost notes along. - Opened from the Assets panel, the pattern shows the item of the last double-click. Without one, a pattern that lies in exactly one item of all the songs shows that item's first round. - Ghost notes are the pattern items of the other tracks all along the song, and on the own track the items before and after and the other rounds of the item. The round edited itself does not count. Sound items have no notes. - A ghost note sits on the row it sounds with: its own transpose, less the transpose of the item the pattern was opened from. - The roll draws them grey, without an edge, under the notes. Resting on one names its track and its pattern. - The header has a Ghosts switch, on by default, and names where they come from, e.g. "Tune · Bass · bar 5". - The time axis pans back to the start of the song, with bar and grid lines before the pattern. Fit still frames only the pattern. - A click or a paste outside the pattern no longer lands. Before, only the time left of bar 1 was closed, notes could still be drawn past the end, where they are kept but never played. - The rounds of a pattern item are now worked out in one place, itemNotes() in song.ts, which songSequence() and the ghost notes share. Tests: - Unit ghosts (new): 5 green. Counter-check with the round edited not left out and the own transpose not taken off: 2 red. - Unit piano-roll: 1 new (pan back to the song, lines before bar 1). Counter-check without the song's start: red. - e2e 65 (melektaus-atx): ALL PASSED - 38 checks (3 new). The first run was red on the tooltip check: the ghost note it rested on lay a fifth up, outside the rows Fit frames. The test now uses a tone. - e2e 64 (melektaus-atx): ALL PASSED - 30 checks. - Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4027 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
876 lines
37 KiB
JavaScript
876 lines
37 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.
|
||
* 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}`;
|
||
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 === busOf(tracks[0]) &&
|
||
Math.abs(firstClip.duration - 2) < 1e-9 &&
|
||
notes.length === 2 &&
|
||
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(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')")));
|
||
const muted = await waitFor(
|
||
() => json('__gm.song.buses()'),
|
||
(list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0,
|
||
);
|
||
check(
|
||
'muting a track while it plays silences its bus at once',
|
||
muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (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 === busOf(tracks[0]) &&
|
||
gameNotes.every((n) => n.bus === busOf(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 takeAsset = await json(
|
||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : 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 named after the song, 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 === `${songName} – Take 1` &&
|
||
/^[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 }),
|
||
);
|
||
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 === busOf(tracks[1]) &&
|
||
plainClip?.bus === busOf(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),
|
||
);
|
||
|
||
// 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));
|
||
__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),
|
||
);
|
||
}
|