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('/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)})`); // 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;`); // 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a // clip round and round, each on the bus of its track. p = await json('__gm.song.rulerAt(0)'); await click(p.x, p.y); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.items.find((i) => i.kind === 'sound').length = ${BAR};`); await press('Space'); const played = await waitFor( () => json('{ planned: __gm.song.planned(), clips: __gm.song.clips(), playing: __gm.song.playing() }'), (s) => s.planned.length >= 2 && s.clips.length >= 1, 60, ); const busOf = (track) => `song:${id}:${track}`; /** Where a track's own items go in, on to its bus (#350). */ const itemsOf = (track) => `${busOf(track)}:#items`; const firstClip = played.clips[0]; const notes = played.planned.slice(0, 2); check( 'Space plays the song: the clip at once, the pattern from bar 3 with its notes a second apart, each on its track', played.playing && Boolean(firstClip) && firstClip.sound === SOUND && firstClip.bus === itemsOf(tracks[0]) && Math.abs(firstClip.duration - 2) < 1e-9 && notes.length === 2 && notes.every((n) => n.instrument === 'sg65-ins' && n.bus === itemsOf(tracks[2]) && n.tempo === 120) && Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 && Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 && notes[0].note === 60, JSON.stringify(played), ); const buses = await json('__gm.song.buses()'); const bus = (track) => buses.find((b) => b.name === busOf(track)); check( 'each track is set on its bus: the turned-down one at -6 dB, the others at unity', Math.abs(bus(tracks[2])?.gain - 10 ** (-6 / 20)) < 1e-6 && bus(tracks[0])?.gain === 1 && bus(tracks[1])?.gain === 1, JSON.stringify(buses), ); await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); // Mute closes every send of the track (#350); its own level stays as it is set. const closed = (list) => { const sends = list.find((b) => b.name === busOf(tracks[0]))?.sends ?? []; return sends.length > 0 && sends.every((send) => send.gain === 0); }; const muted = await waitFor(() => json('__gm.song.buses()'), closed); check( 'muting a track while it plays closes its sends at once', closed(muted) && (await json('__gm.song.playing()')), JSON.stringify(muted), ); await shot('02-playing'); // 65i. A pattern played in its tab takes over; a double-click on a pattern item opens it there. await evaluate(`__gm.state.openAsset(${JSON.stringify(PATTERN)})`); await sleep(200); p = await json('__gm.pattern.playAt()'); await click(p.x, p.y); await sleep(200); const swapped = await json('{ song: __gm.song.playing(), pattern: __gm.pattern.playing() }'); await click(p.x, p.y); check( 'a pattern started in its tab stops the song', swapped.pattern && !swapped.song, JSON.stringify(swapped), ); // The riff a tone up for four bars on the second track: what its ghost notes (#339) show. await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.items.push({ ...song.items.find((i) => i.kind === 'pattern'), id: 'sg65-tone', track: song.tracks[1].id, tick: 0, length: ${4 * BAR}, offset: 0, transpose: 2 });`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); await doubleClick(p.x, p.y); await sleep(200); const opened = await json('{ tab: __gm.tab(), pattern: __gm.pattern.current() }'); check( 'a double-click on a pattern item opens the pattern in its tab', opened.tab === 'pattern' && opened.pattern === PATTERN, JSON.stringify(opened), ); const songNow = { ...(await model()), name: await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`), }; const riffNow = songNow.items.find((i) => i.id === riff.id); const trackName = (trackId) => songNow.tracks.find((tr) => tr.id === trackId).name; const ghosts = await json('__gm.pattern.ghosts()'); const tone = ghosts.notes.filter((n) => n.track === trackName(songNow.tracks[1].id)); const toneTicks = tone.map((n) => n.tick + ghosts.origin); check( 'the pattern opened from the song shows the other track faint: each note where it sounds, a tone up, on by default', ghosts.toggled && ghosts.enabled && tone.length === 8 && toneTicks.every((tick, i) => tick === Math.floor(i / 2) * BAR + (i % 2) * 960) && tone.every((n, i) => n.note === (i % 2 ? 64 : 60) + 2 - riffNow.transpose && n.pattern === 'Riff 65') && (ghosts.origin - riffNow.tick + (riffNow.offset % BAR)) % BAR === 0 && ghosts.source === `${songNow.name} · ${trackName(riffNow.track)} · bar ${Math.floor(Math.max(ghosts.origin, riffNow.tick) / BAR) + 1}`, JSON.stringify({ ghosts, riff: riffNow }), ); const inside = tone.find((n) => n.tick >= 0 && n.tick < BAR); p = await json(`__gm.pattern.gridAt(${inside.tick + 100}, ${inside.note})`); await mouse('mouseMoved', p.x - 2, p.y); await mouse('mouseMoved', p.x, p.y); const sign = await waitFor( () => json('__gm.tooltip()'), (s) => s !== null, ); check( 'resting on a ghost note tells its track and its pattern', sign?.text === `${trackName(songNow.tracks[1].id)} · Riff 65`, JSON.stringify(sign), ); await shot('03-ghosts'); await click(ghosts.x, ghosts.y); await sleep(150); const hidden = await json('__gm.pattern.ghosts()'); await click(ghosts.x, ghosts.y); await sleep(150); check( 'Ghosts hides them and shows them again', !hidden.toggled && hidden.notes.length === 0 && (await json('__gm.pattern.ghosts()')).notes.length === ghosts.notes.length, JSON.stringify({ hidden: hidden.notes.length, toggled: hidden.toggled }), ); // 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the // loop of its ruler, each track on a bus inside a master of the playing. The pause of the // toolbar holds the clock; S fires Stop song, which fades it out, then it is gone. await evaluate('__gm.sound.stopAll()'); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks.forEach((track, i) => Object.assign(track, { mute: false, solo: false, volume: i === 1 ? -6 : 0, pan: i === 1 ? -0.5 : 0 })); const base = song.items[0]; song.items = [ { ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 }, { ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 }, ]; song.loop = { start: ${BAR}, end: ${2 * BAR} };`); await showTab('scene'); const rig = await scaffoldBehavior(t, { assetId: 'sg65-logic', name: 'Music 65', boxId: 'sg65-box', logicId: 'sg65-brain', y: LANES.sound, graph: { nodes: [ node('sg65-start', 'OnStart', 0, 0), node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }), node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }), node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }), ], edges: [ edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'), edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'), ], }, }); const playbacks = () => json('__gm.sound.playbacks()'); await play(); const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60); const [run] = game; const master = `play:${run?.id}`; const gameNotes = run?.planned ?? []; const gameClip = run?.clips[0]; check( 'Play song plays the clip once as the intro, then the pattern round the loop, 2 s a round, on their tracks', game.length === 1 && run.name === `song:${id}` && run.owner !== '' && run.clips.length === 1 && gameClip?.sound === SOUND && gameClip.bus === itemsOf(tracks[0]) && gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) && Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 && Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 && Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6, JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gameNotes }), ); const gameBuses = await json('__gm.song.buses()'); const gameBus = (name) => gameBuses.find((b) => b.name === name); check( 'the playing has a master at its volume, the tracks inside it as they are set', gameBus(master)?.gain === 0.8 && gameBus(`${master}:${busOf(tracks[0])}`)?.gain === 1 && Math.abs(gameBus(`${master}:${busOf(tracks[1])}`)?.gain - 10 ** (-6 / 20)) < 1e-6 && gameBus(`${master}:${busOf(tracks[1])}`)?.pan === -0.5, JSON.stringify(gameBuses), ); await clickToolbar('pause'); await sleep(400); const heldClock = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); await sleep(300); const heldLater = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); await clickToolbar('pause'); await sleep(200); const resumed = await json('__gm.sound.clockState()'); check( 'the pause of the toolbar holds the clock while the song plays, and resuming lets it run', heldClock.state === 'suspended' && heldLater.state === 'suspended' && heldLater.at === heldClock.at && resumed === 'running', JSON.stringify({ heldClock, heldLater, resumed }), ); await press('s'); const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null); const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40); await sleep(100); const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master)); await stop(); check( 'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone', fading.length === 1 && fading[0].fading !== null && fading[0].playing && fadedOut.length === 0 && leftBuses.length === 0, JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }), ); const left = await rig.remove(); // 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a // track of its own under the one clicked, Space keeps it – WAV up, sound, track and item one // undo step –, Esc throws the next one away. await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); const before = await model(); p = await json("__gm.song.headAt(0, 'name')"); await click(p.x, p.y); p = await json(`__gm.song.rulerAt(${BAR})`); await click(p.x, p.y); p = await json('__gm.song.recordAt()'); await click(p.x, p.y); const growing = await waitFor( () => json('{ view: __gm.song.view(), recording: __gm.song.recording(), button: __gm.song.recordAt().text }'), (s) => s.view.take?.peaks > 120, 60, ); await shot('03-recording'); check( 'Record plays from the cursor and grows the take, red, on a new track under the one clicked', growing.recording && growing.button === 'Stop' && growing.view.playhead !== null && growing.view.take?.index === 1 && growing.view.take.tick === BAR && growing.view.take.length > 0 && !growing.view.take.uploading, JSON.stringify(growing), ); await press('Space'); const kept = await waitFor( () => model(), (m) => m.tracks.length === before.tracks.length + 1, 60, ); const takeTrack = kept.tracks[1]; const takeItem = kept.items.find((i) => i.track === takeTrack?.id); const takeId = JSON.stringify(takeItem?.asset ?? ''); const takeAsset = await json( `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${takeId}); return a ? { name: a.name, file: a.file, type: a.type, owner: a.owner } : null; })()`, ); const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`); const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0; check( 'Space keeps it: a WAV sound of the song\'s own, "Take 1", on the track "Recording", from the cursor, as long as it is', kept.tracks[0]?.id === before.tracks[0]?.id && takeTrack?.name === 'Recording' && takeItem?.kind === 'sound' && takeItem.tick === BAR && takeAsset?.type === 'sound' && takeAsset.name === 'Take 1' && takeAsset.owner === id && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(takeAsset.file) && takeSeconds > 1 && Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 && !(await json('__gm.song.recording()')) && (await json('__gm.song.view()')).take === null, JSON.stringify({ kept, takeAsset, takeSeconds }), ); // #355: the take is the song's own – no row in the asset panel, a mark on its item, its song in the tab title. const takeRow = await json(`__gm.assetRow(${takeId})`); const takeLabel = (await json('__gm.song.items()')).find((i) => i.id === takeItem?.id)?.label; p = await json(`__gm.song.itemAt(${JSON.stringify(takeItem?.id ?? '')}, 'body')`); await doubleClick(p.x, p.y); await sleep(200); const takeTab = await json(`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${takeId}] }`); check( 'the take lives in the song: no row in the asset panel, • on its item, a double-click opens "Song › Take 1"', takeRow === null && takeLabel === '• Take 1' && takeTab.tab === 'sound' && takeTab.title === `${songName} › Take 1`, JSON.stringify({ takeRow, takeLabel, takeTab }), ); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); await press('z', { ctrl: true }); const undone = await model(); check( 'one Ctrl+Z takes sound, track and item back', undone.tracks.length === before.tracks.length && undone.items.length === before.items.length && (await json( `__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(takeItem?.asset ?? '')})`, )) === false, JSON.stringify(undone), ); const assetCount = await json('__gm.state.project.assets.length'); p = await json('__gm.song.recordAt()'); await click(p.x, p.y); await waitFor( () => json('__gm.song.view()'), (v) => v.take?.peaks > 20, 60, ); await press('Escape'); await sleep(300); const thrown = await json( '{ recording: __gm.song.recording(), take: __gm.song.view().take, playing: __gm.song.playing(), assets: __gm.state.project.assets.length }', ); check( 'Esc while recording throws the take away: nothing is added, playing stops', !thrown.recording && thrown.take === null && !thrown.playing && thrown.assets === assetCount && (await model()).tracks.length === before.tracks.length, JSON.stringify(thrown), ); // 65m. A sound FX on a track (#346). Offline, the shifter a Pitch on a bus becomes moves a tone an // octave and is late by exactly a frame. In the tab, the FX box of a head picks the FX: all of // the track goes through one chain on its bus, the pattern's notes start a frame earlier and // are heard where they were planned, as the clip of the other track is. Stop lets it go. const shifted = await evaluate(` (async () => { const RATE = 48000; const render = async (pitch, input) => { const context = new OfflineAudioContext(2, RATE, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const buffer = context.createBuffer(1, RATE, RATE); buffer.copyToChannel(input, 0); const source = context.createBufferSource(); source.buffer = buffer; const shifter = new AudioWorkletNode(context, 'pitch-shifter', { numberOfInputs: 1, outputChannelCount: [2], channelCount: 2, channelCountMode: 'explicit', parameterData: { pitch }, }); source.connect(shifter).connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(1); }; const tone = new Float32Array(RATE).map((_, i) => Math.sin((2 * Math.PI * 1000 * i) / RATE)); const up = await render(12, tone); let rises = 0; for (let i = RATE / 4 + 1; i < (RATE * 3) / 4; i++) if (up[i - 1] < 0 && up[i] >= 0) rises++; const click = new Float32Array(RATE); click[4800] = 1; const still = await render(0, click); let peak = 0; for (let i = 1; i < RATE; i++) if (Math.abs(still[i]) > Math.abs(still[peak])) peak = i; return { hz: rises * 2, late: peak - 4800, height: still[peak] }; })()`); check( 'the shifter of a bus moves a 1 kHz tone up to 2 kHz, and at 0 semitones is the sound exactly 2048 samples later', Math.abs(shifted.hz - 2000) <= 20 && shifted.late === 2048 && Math.abs(shifted.height - 1) < 1e-4, JSON.stringify(shifted), ); await mutate(` __gm.state.project.assets.push({ id: 'sg65-fx', type: 'soundfx', name: 'Octave 65', props: {}, graph: { nodes: ${JSON.stringify([ node('sg65-fin', 'SoundFxInput', 0, 0), node('sg65-fpitch', 'SoundFxPitch', 200, 0, { semitones: 12 }), node('sg65-fout', 'SoundFxOutput', 400, 0), ])}, edges: ${JSON.stringify([ edge('sg65-fe1', 'sg65-fin', 'out', 'sg65-fpitch', 'in'), edge('sg65-fe2', 'sg65-fpitch', 'out', 'sg65-fout', 'in'), ])}, }, });`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); p = await json("__gm.song.headAt(1, 'fx')"); await click(p.x, p.y); await sleep(150); await clickText('Octave 65'); await sleep(150); const fxTracks = (await model()).tracks; await shot('04-track-fx'); check( "the FX box of a track's head picks a sound FX for that track", fxTracks[1]?.fx === 'sg65-fx' && fxTracks.every((track, i) => i === 1 || track.fx === ''), JSON.stringify(fxTracks.map((track) => track.fx)), ); p = await json('__gm.song.rulerAt(0)'); await click(p.x, p.y); await press('Space'); const throughFx = await waitFor( () => json('{ planned: __gm.song.planned(), clips: __gm.song.clips() }'), (s) => s.planned.length >= 1 && s.clips.length >= 1, 60, ); const rate = await json('__gm.sound.sampleRate()'); const frame = 2048 / rate; const fxBus = (await json('__gm.song.buses()')).find((b) => b.name === busOf(tracks[1])); const [fxNote] = throughFx.planned; const [plainClip] = throughFx.clips; const noteStart = await json(`__gm.sound.startedAt(${fxNote?.voice ?? 0})`); const clipStart = await json(`__gm.sound.startedAt(${plainClip?.voice ?? 0})`); check( 'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time', fxBus?.fx === true && Math.abs(fxBus.latency - frame) < 1e-9 && fxNote?.bus === itemsOf(tracks[1]) && plainClip?.bus === itemsOf(tracks[0]) && Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 && Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 && Math.abs(clipStart - plainClip.at) < 1e-6, JSON.stringify({ rate, fxBus, fxNote, plainClip, noteStart, clipStart }), ); await press('Space'); const released = await waitFor( () => json('__gm.song.buses()'), (list) => !list.some((b) => b.name === busOf(tracks[1])), ); check( 'stopping lets the buses of the tab go, the chain on them too', !(await json('__gm.song.playing()')) && !released.some((b) => b.name.startsWith(`song:${id}:`)), JSON.stringify(released), ); // 65l. Routing (#350): the switch in the header shows the song as a graph, a node per track and // the output, every track linked to it. A drag from one track's out into another's in sends // it there, one back would close a loop and is refused, Delete takes a link but not a track. // A track linked nowhere says it is not heard. Volume and Mode of a link in the inspector // are what its send is set to when the song plays. await evaluate('__gm.sound.stopAll()'); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); const magnetOnTimeline = await json('__gm.song.routing.snapButton()'); p = await json("__gm.song.viewAt('routing')"); await click(p.x, p.y); await sleep(300); // The magnet is the routing's, as in every graph tab: off 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), ); }