Files
feuerwerk/tools/e2e/65-song.mjs
T
adminandClaude Opus 5.5 56425fca6a
feuerwerk / gate (push) Successful in 5m22s
feuerwerk / suite (push) Failing after 19m54s
Let a song carry named markers on a strip in the song tab (#338)
Stage 1 of #338. Game sync needs named places in a song: an event when
the playhead passes one, and a switch that jumps to one. This stage
adds the markers themselves. Games do nothing with them yet.

- A song has markers, each with id, name and tick. A song without
  markers stores nothing for them. Two markers may share tick and
  name, since nodes will name them by id.
- Markers change neither the sound nor the bake fingerprint.
- The song tab draws a strip under the ruler. A marker has a green
  flag with its name and a faint line through the tracks. Tracks and
  lanes sit 18 px lower. The ruler of the pattern tab is unchanged.
- Double click on an empty place adds a marker on the grid, with Alt
  or Shift on the exact tick. Dragging moves it in one undo step.
- A click on a marker sets the start of playback there. A double click
  renames it in the name dialog of the hierarchy groups.
- The context menu offers Play from here, Rename and Delete marker,
  on an empty place Add marker. A tooltip shows the full name. The
  wheel over the strip zooms time like the ruler.
- New agent commands add_song_marker, change_song_marker and
  remove_song_marker. get_asset lists them under markers.

Tests:
- song-markers in core 6 and editor 4, 1 agent test, with the touched
  song, agent and MCP tests 175/175
- 9 counter-probes, all compile, all red
- e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe
  where a dragged marker is not taken over: 65g3 red
- gate on atx: CHECK EXIT 0, 332 files, 5093 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:44:58 +02:00

1669 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
import { sampleZoom } from './lib/zoomTransition.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('/feuerwerk/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)})`);
// A new song snaps to 1/16; the checks below go by bars, so the grid is set to a bar here.
const newGrid = await json('__gm.song.gridAt()');
await click(newGrid.x, newGrid.y);
await sleep(150);
const barEntry = (await json("__gm.locateAll('Bar')")).find((hit) => hit.y > newGrid.y);
if (barEntry) await click(barEntry.x, barEntry.y);
await sleep(150);
const barGrid = await json('__gm.song.gridAt()');
check(
'a new song snaps to 1/16, and its Grid menu sets a bar',
newGrid.text === '1/16' && barGrid.text === 'Bar',
JSON.stringify({ newGrid, barGrid }),
);
/**
* Adds a track under the last one through its head's menu: there is no other way to. Fit first:
* the tracks a fit shares the height out to leave the one added last below the edge.
*/
const addTrackBelowLast = async () => {
const fit = await json('__gm.song.fitAt()');
await click(fit.x, fit.y);
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);
// 65a-ii. Fit takes the song across and its four tracks down, with some room on every side: the
// bar starts right of the heads and ends left of the edge, the last track ends above it.
const fitButton = await json('__gm.song.fitAt()');
await click(fitButton.x, fitButton.y);
const fitted = await json('__gm.song.view()');
const area = await json('__gm.song.rect()');
const fitEnds = await json(`[__gm.song.laneAt(0, 0), __gm.song.laneAt(${BAR}, 3)]`);
const bottom = fitEnds[1].y + fitted.trackHeight / 2;
check(
'Fit leaves room around the song: across the bar and down all four tracks',
fitted.panX > 0 &&
fitted.panY > 0 &&
fitEnds[1].x < area.x + area.w - 20 &&
bottom < area.y + area.h - 20 &&
bottom > area.y + area.h * 0.8,
JSON.stringify({ fitted, area, fitEnds }),
);
// 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.
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. 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');
// The song is written in one Sign (#363): picked from its list, then back to 4/4.
const signRows = await json("[__gm.inspectorField('Sign'), __gm.inspectorField('Beats per bar')]");
const pickSign = async (sign) => {
const field = await json("__gm.inspectorField('Sign')");
await click(field.x, field.y);
await sleep(150);
await clickText(sign);
await sleep(150);
return json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})?.props.sign ?? null`);
};
const signs = [await pickSign('6/8'), await pickSign('4/4')];
check(
'the song is written in one Sign, picked from its list',
signRows[0] !== null && signRows[1] === null && signs.join() === '6/8,4/4',
JSON.stringify({ signRows, signs }),
);
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 }),
);
// #377: at 1000 ms a frame after the wheel the zoom is on its way, on the heads and on the lanes alike.
const songZoom = (key) => `({ scale: __gm.song.view().${key}, moving: __gm.song.view().moving })`;
const songTracks = await sampleZoom(t, await json("__gm.song.headAt(0, 'name')"), songZoom('trackHeight'));
const songTime = await sampleZoom(t, await json(`__gm.song.laneAt(${2 * BAR}, 0)`), songZoom('pxPerTick'));
check(
'a zoom of the tracks and of the time is shown on its way and lands where the jump does',
songTracks.ok && songTime.ok,
`${songTracks.detail} ${songTime.detail}`,
);
// 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;`);
// 65g2. Automation lanes (#337). The triangle in a head opens a lane for the volume and one for the
// pan right under its track, and what lies below moves down. A click on a lane sets a point on
// the grid, a press dragged on sets one and takes it along, as one step. A dragged point stops
// at its neighbour. A point's menu sets how it eases to the next, it says its value when the
// pointer rests on it, and a double-click takes it away. The fader a lane rules does not move.
// Clear lane leaves the lane open and empty, the triangle closes it.
const lanesShown = () => json('__gm.song.automation()');
const volumeAt = (tick, value) => json(`__gm.song.automationAt(1, 'volume', ${tick}, ${value})`);
const volumePoints = async () =>
(await model()).tracks[1].automation?.find((lane) => lane.target === 'volume')?.points ?? [];
const lowItem = (await model()).items.find((item) => item.track !== tracks[0] && item.track !== tracks[1]);
const lowY = async () => (await json('__gm.song.items()')).find((item) => item.id === lowItem?.id)?.box.y;
const lowBefore = await lowY();
const rowHeight = (await json('__gm.song.view()')).trackHeight;
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const lanesOpened = await lanesShown();
const lowAfter = await lowY();
check(
'the triangle of a head opens a volume and a pan lane under its track, and what lies below moves down',
JSON.stringify(lanesOpened.rows.map((row) => row.lane)) ===
JSON.stringify([null, null, 'volume', 'pan', null, null, null]) &&
lanesOpened.rows[2].track === tracks[1] &&
lanesOpened.rows[3].track === tracks[1] &&
lowItem !== undefined &&
Math.abs(lowAfter - lowBefore - 2 * rowHeight) < 0.5,
JSON.stringify({ lanesOpened, lowBefore, lowAfter, rowHeight }),
);
p = await volumeAt(BAR + 100, -12);
await click(p.x, p.y);
await sleep(100);
const onePoint = await volumePoints();
p = await volumeAt(3 * BAR, -30);
to = await volumeAt(4 * BAR + 50, 0);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const twoPoints = await volumePoints();
const addedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await sleep(100);
const laneUndone = await volumePoints();
await clickToolbar('redo');
await sleep(100);
const laneRedone = await volumePoints();
check(
'a click on a lane sets a point on the bar, a drag sets one and takes it along as one step',
onePoint.length === 1 &&
onePoint[0].tick === BAR &&
Math.abs(onePoint[0].value + 12) < 1.5 &&
twoPoints.length === 2 &&
twoPoints[1].tick === 4 * BAR &&
Math.abs(twoPoints[1].value) < 1.5 &&
addedLabel === 'Add automation point' &&
laneUndone.length === 1 &&
JSON.stringify(laneRedone) === JSON.stringify(twoPoints),
JSON.stringify({ onePoint, twoPoints, addedLabel, laneUndone, laneRedone }),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
to = await volumeAt(6 * BAR, twoPoints[0].value);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const heldPoints = await volumePoints();
await press('z', { ctrl: true });
await sleep(100);
check(
'a point dragged past its neighbour stops on its tick: two points there, a jump',
heldPoints.length === 2 &&
heldPoints[0].tick === 4 * BAR &&
heldPoints[1].tick === 4 * BAR &&
Math.abs(heldPoints[0].value - twoPoints[0].value) < 0.2 &&
JSON.stringify(await volumePoints()) === JSON.stringify(twoPoints),
JSON.stringify(heldPoints),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
await click(p.x, p.y, 'right');
await sleep(150);
const pointRows = await json('__gm.menuRows()');
await clickText('Ease to next point');
await sleep(150);
await clickText('Sine');
await sleep(150);
await clickText('In');
await sleep(150);
const eased = await volumePoints();
const shownValue = (v) =>
`${Math.round(v * 10) / 10 > 0 ? '+' : ''}${(Math.round(v * 10) / 10).toFixed(1)} dB`;
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await mouse('mouseMoved', p.x - 1, p.y);
await mouse('mouseMoved', p.x, p.y);
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a point of a lane' });
const pointSign = await json('__gm.tooltip()');
check(
"a point's menu sets how it eases to the next, and resting on a point says its value",
pointRows?.includes('Ease to next point') &&
pointRows.includes('Delete point') &&
eased[0].ease === 'sine-in' &&
eased[1].ease === undefined &&
pointSign?.text === shownValue(twoPoints[1].value),
JSON.stringify({ pointRows, eased, pointSign }),
);
p = await json("__gm.song.headAt(1, 'volume', 0.5)");
to = await json("__gm.song.headAt(1, 'volume', 0)");
await drag(p.x, p.y, to.x + 1, to.y);
await sleep(100);
const ruledFader = { volume: (await model()).tracks[1].volume, notice: await json('__gm.state.notice') };
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await doubleClick(p.x, p.y);
await sleep(100);
const afterDouble = await volumePoints();
check(
'the fader a lane rules does not move, and a double-click takes a point away',
ruledFader.volume === 0 &&
ruledFader.notice.startsWith('Volume follows its automation lane') &&
afterDouble.length === 1 &&
afterDouble[0].tick === BAR,
JSON.stringify({ ruledFader, afterDouble }),
);
p = await json("__gm.song.automationHeadAt(1, 'volume')");
await click(p.x, p.y, 'right');
await sleep(150);
await clickText('Clear lane');
await sleep(100);
const laneCleared = { track: (await model()).tracks[1], rows: (await lanesShown()).rows.length };
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const closedRows = (await lanesShown()).rows.length;
check(
'Clear lane takes the lane out of the song and leaves it open, the triangle closes the lanes',
!('automation' in laneCleared.track) &&
laneCleared.rows === 7 &&
closedRows === 5 &&
(await lowY()) === lowBefore,
JSON.stringify({ laneCleared, closedRows }),
);
await evaluate("__gm.state.notice = ''");
// 65g3. Markers (#338) on their strip under the ruler. A double-click there adds one on the grid,
// a drag moves it as one step, a click starts playing at it, a double-click names it, resting
// on it says its name, and its menu takes it away.
const songMarkers = async () => (await model()).markers;
const markerAt = (tick) => json(`__gm.song.markerAt(${tick})`);
p = await markerAt(BAR + 100);
await doubleClick(p.x, p.y);
await sleep(100);
const added = await songMarkers();
p = await markerAt(BAR);
to = await markerAt(3 * BAR + 50);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const movedMarkers = await songMarkers();
const movedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await sleep(100);
const markerUndone = await songMarkers();
await clickToolbar('redo');
await sleep(100);
const markerDrawn = (await json('__gm.song.markers()'))[0];
const rulerX = (await json(`__gm.song.rulerAt(${3 * BAR})`)).x;
check(
'a double-click on the strip adds a marker on the bar, a drag moves it as one step, drawn at its tick',
added.length === 1 &&
added[0].name === 'Marker 1' &&
added[0].tick === BAR &&
movedMarkers[0].tick === 3 * BAR &&
movedLabel === 'Move marker' &&
markerUndone[0].tick === BAR &&
(await songMarkers())[0].tick === 3 * BAR &&
Math.abs(markerDrawn?.x - rulerX) < 1,
JSON.stringify({ added, movedMarkers, movedLabel, markerUndone, markerDrawn, rulerX }),
);
p = await markerAt(3 * BAR);
await click(p.x, p.y);
await sleep(100);
const startAtMarker = (await json('__gm.song.view()')).start;
await sleep(500);
await doubleClick(p.x, p.y);
await sleep(150);
const nameDialog = await json('__gm.groupDialogOpen()');
await t.typeText('Boss');
await press('Enter');
await sleep(100);
const named = (await songMarkers())[0];
// The presses left the marker's sign quiet: the pointer goes off the strip and back.
to = await json(`__gm.song.rulerAt(${3 * BAR})`);
await mouse('mouseMoved', to.x, to.y);
await mouse('mouseMoved', p.x + 5, p.y);
await mouse('mouseMoved', p.x + 6, p.y);
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a marker' });
const markerSign = await json('__gm.tooltip()');
check(
'a click on a marker starts playing there, a double-click names it, and resting on it says the name',
startAtMarker === 3 * BAR && nameDialog === true && named?.name === 'Boss' && markerSign?.text === 'Boss',
JSON.stringify({ startAtMarker, nameDialog, named, markerSign }),
);
await click(p.x, p.y, 'right');
await sleep(150);
const markerRows = await json('__gm.menuRows()');
await clickText('Delete marker');
await sleep(100);
const markersLeft = await model();
check(
"a marker's menu plays from it, renames it and takes it away",
['Play from here', 'Rename…', 'Delete marker'].every((row) => markerRows?.includes(row)) &&
markersLeft.markers.length === 0,
JSON.stringify({ markerRows, markers: markersLeft.markers }),
);
// 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),
);
// 65h2. A lane of the second track's volume (#337), -40 dB to 0 dB over eight bars, takes its bus
// along while the song plays, from the window after it is set on: the level follows the
// playhead, and the fader turned all the way down meanwhile does not move it. The loop is
// made long, so the playhead does not jump back while it is read.
const loopBefore = (await model()).loop;
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.loop = { start: 0, end: ${64 * BAR} };
song.tracks[1].automation = [{ target: 'volume', points: [{ tick: 0, value: -40 }, { tick: ${8 * BAR}, value: 0 }] }];`);
const laneDb = (ticks) => -40 + (40 * ticks) / (8 * BAR);
const readLane = () =>
json(`({
level: __gm.song.buses().find((b) => b.name === ${JSON.stringify(busOf(tracks[1]))})?.level,
at: __gm.song.position()?.ticks,
})`);
await sleep(300);
const laneFirst = await readLane();
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks[1].volume = -60;`);
await sleep(1000);
const laneLater = await readLane();
const offLane = (r) => Math.abs(20 * Math.log10(r.level) - laneDb(r.at));
check(
'a lane of the volume takes the bus of its track along the playhead, and the fader no longer moves it',
[laneFirst, laneLater].every((r) => r.level > 0 && r.at < 8 * BAR && offLane(r) < 1) &&
laneLater.at - laneFirst.at > 800 &&
20 * Math.log10(laneLater.level / laneFirst.level) > 2,
JSON.stringify({ laneFirst, laneLater, off: [laneFirst, laneLater].map(offLane) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete song.tracks[1].automation;
song.tracks[1].volume = 0;`);
// 65h3. A lane of a parameter (#337, stage 3): the sound item of the first track goes through a
// sound FX whose parameter `cut` is its gain in dB, and starts again a bar on, eight bars
// long. Its clip starts at the value the lane has there, and its knob follows the lane while
// it plays: one voice, read twice.
const cutItem = (await model()).items.find((i) => i.kind === 'sound');
const itemBefore = JSON.stringify(cutItem);
const startAt = Math.ceil(((await json('__gm.song.position()')).ticks + BAR) / BAR) * BAR;
await mutate(`
__gm.state.project.assets.push({
id: 'sg65-fx',
type: 'soundfx',
name: 'Cut 65',
props: {},
graph: {
nodes: ${JSON.stringify([
node('sg65-fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'cut', kind: 'number', default: 0 }]),
node('sg65-fx-gain', 'SoundFxGain', 200, 0),
node('sg65-fx-out', 'SoundFxOutput', 400, 0),
])},
edges: ${JSON.stringify([
edge('sg65-fx-e1', 'sg65-fx-in', 'out', 'sg65-fx-gain', 'in'),
edge('sg65-fx-e2', 'sg65-fx-in', 'cut', 'sg65-fx-gain', 'gain'),
edge('sg65-fx-e3', 'sg65-fx-gain', 'out', 'sg65-fx-out', 'in'),
])},
},
});
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const item = song.items.find((i) => i.id === ${JSON.stringify(cutItem.id)});
Object.assign(item, { fx: 'sg65-fx', tick: ${startAt}, length: ${8 * BAR} });
song.tracks[0].automation = [
{ target: 'param:cut', min: -40, max: 0, points: [{ tick: 0, value: -40 }, { tick: ${16 * BAR}, value: 0 }] },
];`);
const cutDb = (ticks) => -40 + (40 * ticks) / (16 * BAR);
const readCut = () =>
json(`({
voices: __gm.sound.levels(${JSON.stringify(blip?.file ?? '')}).filter((knobs) => knobs.length > 0),
at: __gm.song.position()?.ticks,
clip: __gm.song.clips().filter((c) => c.fx === 'sg65-fx').at(-1) ?? null,
})`);
const cutFirst = await waitFor(readCut, (r) => r.voices.length > 0 && r.at > startAt + 0.25 * BAR, 40);
await sleep(1500);
const cutLater = await readCut();
const offCut = (r) => Math.abs(20 * Math.log10(r.voices[0]?.at(-1) ?? 0) - cutDb(r.at));
check(
'a lane of a parameter starts a clip at its value there and takes its knob along while it plays',
[cutFirst, cutLater].every((r) => r.voices.length === 1 && offCut(r) < 1) &&
Math.abs(cutFirst.clip?.params?.cut - cutDb(startAt)) < 0.01 &&
cutLater.at - cutFirst.at > 600 &&
20 * Math.log10(cutLater.voices[0].at(-1) / cutFirst.voices[0].at(-1)) > 1,
JSON.stringify({ startAt, cutFirst, cutLater, off: [cutFirst, cutLater].map(offCut) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const at = song.items.findIndex((i) => i.id === ${JSON.stringify(cutItem.id)});
song.items[at] = ${itemBefore};
delete song.tracks[0].automation;
__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'sg65-fx');
${loopBefore ? `song.loop = ${JSON.stringify(loopBefore)};` : 'delete song.loop;'}`);
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');
// The clip ends where the note starts, and the note is planned only the lookahead and a frame
// before: by then the clip's voice may be gone, and with it its start (#415). So the start of the
// clip is kept from the first reading that has it, each reading in one go with the clock.
let clipStart = null;
const throughFx = await waitFor(
async () => {
const seen = await json(`(() => {
const planned = __gm.song.planned();
const clips = __gm.song.clips();
const startOf = (list) => (list[0] ? __gm.sound.startedAt(list[0].voice) : null);
return { planned, clips, noteStart: startOf(planned), clipStart: startOf(clips), clock: __gm.sound.clock() };
})()`);
clipStart ??= seen.clipStart;
return seen;
},
(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, clock } = throughFx;
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, clock }),
);
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 as the harness left it (#391), 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.sign === '4/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 }),
);
// 65q. Level meters (#359): a sound item and its track each turned up 12 dB clip. The meter of
// its track and Out go past 0 dB and light their clip lamps, while the limiter at the output
// keeps every sample under -1 dBFS. The lamps stay lit after Stop while the taps go; a click
// on a meter puts its lamp out.
await evaluate('__gm.sound.stopAll()');
const hotItem = (await model()).items.find((item) => item.kind === 'sound');
const hotRow = (await model()).tracks.findIndex((track) => track.id === hotItem.track);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
// Every track up, since 65l sends the first through the second at -6 dB.
for (const track of song.tracks) Object.assign(track, { mute: false, solo: false, volume: 12 });
song.items.find((i) => i.id === ${JSON.stringify(hotItem.id)}).volume = 12;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
await sleep(200);
p = await json(`__gm.song.rulerAt(${hotItem.tick})`);
await click(p.x, p.y);
await press('Space');
const hot = await waitFor(
() => json('__gm.song.meters()'),
(m) => m.out.clipped && m.tracks[hotItem.track]?.some((side) => side.clipped),
40,
);
const hotTrack = hot.tracks[hotItem.track];
check(
'a track and Out past 0 dB: their meters rise above 0 dB and light the clip lamp, each track and Out tapped',
hot.out.clipped &&
hot.out.loudest > 0 &&
hotTrack?.some((side) => side.clipped) &&
hot.metered === (await model()).tracks.length + 1,
JSON.stringify(hot),
);
// Every frame for a few seconds, so the short blip is not missed between two asks.
const guarded = await evaluate(`new Promise((done) => {
const until = performance.now() + 6000;
let most = 0;
const step = () => {
const sides = __gm.song.guard();
if (sides) most = Math.max(most, ...sides);
if (performance.now() < until) requestAnimationFrame(step);
else done({ most, loudest: __gm.song.meters().out.loudest });
};
step();
})`);
check(
'behind the limiter at the output no sample of it passes -1 dBFS',
guarded.most > 0.3 && guarded.most <= 10 ** (-1 / 20) + 1e-4 && guarded.loudest > 0,
JSON.stringify(guarded),
);
await shot('03-meters');
await press('Space');
await sleep(150);
const after = await json('__gm.song.meters()');
p = await json(`__gm.song.meterAt(${hotRow})`);
await click(p.x, p.y);
await click(...Object.values(await json('__gm.song.outAt()')));
await sleep(100);
const cleared = await json('__gm.song.meters()');
check(
'Stop lets the taps go and the bars fall, the clip lamps stay lit until a click on the meter',
after.metered === 0 &&
after.out.clipped &&
after.tracks[hotItem.track].some((side) => side.clipped) &&
after.tracks[hotItem.track].every((side) => side.level === null) &&
!cleared.out.clipped &&
cleared.out.loudest === null &&
cleared.tracks[hotItem.track].every((side) => !side.clipped),
JSON.stringify({ after, cleared }),
);
// 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),
);
}