import { edge, node } from '../lib/builders.mjs'; import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; /** * Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds * three more. A pattern and a * sound dragged in from the Assets panel land as items where the preview showed them; an item's * right end lengthens it, its body moves it across tracks, its left end cuts it back or pulls it * out earlier, an end snaps to the end of a round, Ctrl+V lands under the pointer, Ctrl+D puts a * copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click * on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the * wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets * the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler: * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record * takes chromium's test tone onto a track of its own (#342), Esc throws a take away. * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips * and what the buses were set to, as in e2e 64. * * Everything it adds carries a `sg65` id or is the song it made, and goes again at the end. */ const BLIP_BYTES = 4044; const BAR = 1920; const PATTERN = 'sg65-pat'; const SOUND = 'sg65-snd'; export async function song(t) { const { check, sleep, json, evaluate, mutate, mouse, click, clickText, doubleClick, press, drag, shot, showTab, play, stop, clickToolbar, } = t; const waitFor = async (read, done, tries = 25) => { let value = await read(); for (let i = 0; i < tries && !done(value); i++) { await sleep(150); value = await read(); } return value; }; /** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */ const dragAsset = async (assetId, to) => { const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`); await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 8; i++) { await mouse( 'mouseMoved', from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8, 'left', { buttons: 1, }, ); await sleep(16); } const drop = (await json('__gm.song.view()')).drop; await mouse('mouseReleased', to.x, to.y); await sleep(200); return drop; }; await press('Escape'); await evaluate('__gm.state.select(null)'); await showTab('scene'); const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); const blip = stored.find((item) => item.bytes === BLIP_BYTES); await mutate(` __gm.state.project.assets.push( { id: ${JSON.stringify(SOUND)}, type: 'sound', name: 'Blip 65', props: {}, file: ${JSON.stringify(blip?.file ?? '')} }, { id: 'sg65-ins', type: 'instrument', name: 'Lead 65', props: {}, graph: { nodes: ${JSON.stringify([ node('sg65-notes', 'InstrumentNotes', 0, 0, { from: 48, to: 84 }), node('sg65-sample', 'InstrumentSample', 200, 0, { sound: SOUND }), node('sg65-out', 'InstrumentOutput', 420, 0), ])}, edges: ${JSON.stringify([ edge('sg65-e1', 'sg65-notes', 'any', 'sg65-sample', 'trigger'), edge('sg65-e2', 'sg65-sample', 'out', 'sg65-out', 'in'), ])}, }, }, { id: ${JSON.stringify(PATTERN)}, type: 'pattern', name: 'Riff 65', props: { instrument: 'sg65-ins', bars: 1 }, notes: [ { id: 'sg65-n1', tick: 0, length: 480, note: 60, velocity: 1 }, { id: 'sg65-n2', tick: 960, length: 480, note: 64, velocity: 0.5 }, ], }, );`); const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`); // 65a. "+ → Song" makes a song of one empty track and opens its tab; a head's menu adds the rest. await clickText('+'); await pickAssetType(t, 'Song'); const created = await json(`{ tab: __gm.tab(), current: __gm.song.current(), info: __gm.song.info(), songs: __gm.state.project.assets.filter((a) => a.type === 'song').map((a) => ({ id: a.id, tracks: a.tracks.length })), }`); check( 'the + menu creates a song of one empty track and opens its tab', created.tab === 'song' && created.songs.length === 1 && created.current === created.songs[0].id && created.songs[0].tracks === 1 && created.info === '1 track · 0 items', JSON.stringify(created), ); const id = created.songs[0]?.id; const model = () => json(`__gm.song.model(${JSON.stringify(id)})`); /** Adds a track under the last one through its head's menu: there is no other way to. */ const addTrackBelowLast = async () => { const count = (await model()).tracks.length; const head = await json(`__gm.song.headAt(${count - 1}, 'name')`); await click(head.x, head.y, 'right'); await sleep(150); await clickText('Add track below'); await sleep(100); }; for (let i = 0; i < 3; i++) await addTrackBelowLast(); const tracks = (await model()).tracks.map((track) => track.id); // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. // A pattern dragged onto the second track lands on the bar before the pointer, a bar long. const fitted = await json('__gm.song.view()'); const area = await json('__gm.song.rect()'); for (let i = 0; i < 30; i++) { const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`); if (far.x < area.x + area.w) break; const near = await json(`__gm.song.laneAt(0, 0)`); await t.wheel(near.x + 2, near.y, 200); } const zoomedOut = await json('__gm.song.view()'); check( 'an empty song fits a single bar across, and the wheel over the lanes takes the view out', fitted.pxPerTick > zoomedOut.pxPerTick * 4, JSON.stringify({ fitted, zoomedOut }), ); const preview = await dragAsset(PATTERN, await json(`__gm.song.laneAt(${BAR + 400}, 1)`)); let items = (await model()).items; const riff = items[0]; check( 'a pattern dragged in from the Assets panel shows where it lands and lands there, picked', preview?.kind === 'pattern' && preview.track === tracks[1] && preview.tick === BAR && items.length === 1 && riff.kind === 'pattern' && riff.asset === PATTERN && riff.track === tracks[1] && riff.tick === BAR && riff.length === BAR && (await json('__gm.song.picked()')).join() === riff.id && (await json('__gm.state.selection?.kind')) === 'song', JSON.stringify({ preview, items }), ); // 65c. A sound dragged onto the first track is as long as it plays at 120 BPM. await dragAsset(SOUND, await json('__gm.song.laneAt(300, 0)')); items = (await model()).items; const clip = items.find((item) => item.kind === 'sound'); const drawn = await waitFor( () => json('__gm.song.items()'), (list) => list.find((item) => item.id === clip?.id)?.content === 'wave', ); check( 'a sound dropped on a track lasts as long as the sound at the tempo, and shows its waveform', clip?.track === tracks[0] && clip.tick === 0 && clip.length === Math.max(1, Math.round(seconds * 960)) && drawn.find((item) => item.id === riff.id)?.content === 'notes' && drawn.find((item) => item.id === clip.id)?.label === 'Blip 65', JSON.stringify({ seconds, clip, drawn }), ); await shot('01-dropped'); // 65d. The pattern's right end takes it to three bars, its body to the third track a bar later. let p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'end')`); let to = await json(`__gm.song.laneAt(${4 * BAR}, 1)`); await drag(p.x, p.y, to.x, to.y); await sleep(200); const lengthened = (await model()).items.find((item) => item.id === riff.id); p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); const { pxPerTick } = await json('__gm.song.view()'); to = await json(`__gm.song.laneAt(0, 2)`); await drag(p.x, p.y, p.x + BAR * pxPerTick, to.y); await sleep(200); const movedRiff = (await model()).items.find((item) => item.id === riff.id); check( 'its right end lengthens the pattern to three bars, its body takes it a bar on and a track down', lengthened?.length === 3 * BAR && movedRiff?.tick === 2 * BAR && movedRiff.track === tracks[2], JSON.stringify({ lengthened, movedRiff }), ); // 65e. Its left end cuts a bar off the front: it then starts a bar into what it repeats. p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`); to = await json(`__gm.song.laneAt(${3 * BAR}, 2)`); await drag(p.x, p.y, to.x, to.y); await sleep(200); const trimmedRiff = (await model()).items.find((item) => item.id === riff.id); check( 'its left end cuts the front off, and what it plays starts that far in', trimmedRiff?.tick === 3 * BAR && trimmedRiff.length === 2 * BAR && trimmedRiff.offset === BAR, JSON.stringify(trimmedRiff), ); await press('z', { ctrl: true }); check( 'one undo takes the cut back', JSON.stringify((await model()).items.find((item) => item.id === riff.id)) === JSON.stringify(movedRiff), ); // 65e-ii. Its left end pulls it out a bar earlier, repeating what it plays; the sound's right end // snaps to the end of its second round, not to the bar; Ctrl+V lands under the pointer. p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`); to = await json(`__gm.song.laneAt(${BAR}, 2)`); await drag(p.x, p.y, to.x, to.y); await sleep(200); const pulledOut = (await model()).items.find((item) => item.id === riff.id); await press('z', { ctrl: true }); const round = seconds * 960; p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`); to = await json(`__gm.song.laneAt(${Math.round(2.1 * round)}, 0)`); await drag(p.x, p.y, to.x, to.y); await sleep(200); const twoRounds = (await model()).items.find((item) => item.id === clip.id); await press('z', { ctrl: true }); // The sound is picked from its drag; a click on the pattern here would make 65f's a double-click. await press('c', { ctrl: true }); p = await json(`__gm.song.laneAt(${6 * BAR + 500}, 1)`); await mouse('mouseMoved', p.x, p.y); await press('v', { ctrl: true }); const pastedItem = (await model()).items.find((item) => item.id !== riff.id && item.id !== clip.id); await press('z', { ctrl: true }); check( 'the left end pulls an item out earlier, the right end snaps to the end of a round, and Ctrl+V lands under the pointer', pulledOut?.tick === BAR && pulledOut.length === 4 * BAR && pulledOut.offset === 0 && twoRounds?.length === Math.floor(2 * round + 1e-6) && pastedItem?.tick === 6 * BAR && pastedItem.track === tracks[1] && (await model()).items.length === 2, JSON.stringify({ pulledOut, round, twoRounds, pastedItem }), ); // 65f. Ctrl+D puts a copy right behind it; a click on the ruler and Ctrl+E split the copy there. p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); await click(p.x, p.y); await press('d', { ctrl: true }); items = (await model()).items; const copy = items.find((item) => item.id !== riff.id && item.kind === 'pattern'); check( 'Ctrl+D puts a copy right behind the item on its track, picked', items.length === 3 && copy?.tick === 5 * BAR && copy.length === 3 * BAR && copy.track === tracks[2] && (await json('__gm.song.picked()')).join() === copy.id, JSON.stringify(items), ); p = await json('__gm.song.fitAt()'); await click(p.x, p.y); // Three quarters into a bar: the start goes to the nearest bar line, the next one, not back. p = await json(`__gm.song.rulerAt(${5 * BAR + 1500})`); await click(p.x, p.y); await press('e', { ctrl: true }); items = (await model()).items; const parts = items.filter((item) => item.tick >= 5 * BAR).sort((a, b) => a.tick - b.tick); check( 'a click on the ruler sets the start on the nearest bar line, and Ctrl+E splits the picked item there', (await json('__gm.song.view()')).start === 6 * BAR && parts.length === 2 && parts[0].length === BAR && parts[1].tick === 6 * BAR && parts[1].length === 2 * BAR && parts[1].offset === BAR && (await json('__gm.song.picked()')).join() === parts[1].id, JSON.stringify(parts), ); await press('Delete'); p = await json(`__gm.song.itemAt(${JSON.stringify(parts[0].id)}, 'body')`); await click(p.x, p.y, 'right'); await sleep(150); const menuDelete = await json("__gm.locate('Delete', true)"); const beforeMenu = (await model()).items.length; if (menuDelete) await click(menuDelete.x, menuDelete.y); await sleep(150); items = (await model()).items; check( 'Delete takes an item away, and a right-click opens a menu whose "Delete" does', beforeMenu === 3 && Boolean(menuDelete) && items.length === 2 && items.every((item) => item.id === riff.id || item.id === clip.id), JSON.stringify({ beforeMenu, menuDelete, items }), ); // 65f-ii. A click on nothing picks the song; a sound item turns up to +12 dB in the inspector, which // has no delete button any more. An end of an item under the pointer shows it can be dragged, // and an item longer than what it plays marks where it starts over. p = await json('__gm.song.laneAt(300, 3)'); await click(p.x, p.y); const emptyPick = await json('__gm.state.selection'); p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`); await click(p.x, p.y); await sleep(150); const volumeField = await json("__gm.inspectorField('Volume (dB)')"); await click(volumeField.x, volumeField.y); await t.typeText('30'); await press('Enter'); await sleep(150); const loud = (await model()).items.find((item) => item.id === clip.id); const deleteButtons = await json("[__gm.locate('Delete item'), __gm.locate('Delete items')]"); p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`); await mouse('mouseMoved', p.x - 1, p.y); await mouse('mouseMoved', p.x, p.y); const onEnd = await json('__gm.song.view()'); p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`); await mouse('mouseMoved', p.x, p.y); const onBody = await json('__gm.song.view()'); const seams = (await json('__gm.song.items()')).find((item) => item.id === riff.id)?.seams; check( 'a click on nothing picks the song, a sound item goes to +12 dB, an end lights up under the pointer, and a long item marks its rounds', emptyPick?.kind === 'asset' && emptyPick.assetId === id && loud?.volume === 12 && deleteButtons.every((b) => b === null) && onEnd.hover?.id === clip.id && onEnd.hover.part === 'end' && onEnd.cursor === 'ew-resize' && onBody.hover?.part === 'body' && onBody.cursor === 'default' && seams?.join() === [3 * BAR, 4 * BAR].join(), JSON.stringify({ emptyPick, loud, deleteButtons, onEnd, onBody, seams }), ); await shot('01b-hover'); // 65f-iii. The wheel zooms the tracks over their heads and the time over the lanes; a drag on the // strip above the ruler sets the loop, Ctrl+Z takes it back. const unzoomed = await json('__gm.song.view()'); p = await json("__gm.song.headAt(0, 'name')"); await t.wheel(p.x, p.y, -200); const taller = await json('__gm.song.view()'); p = await json(`__gm.song.laneAt(${2 * BAR}, 0)`); await t.wheel(p.x, p.y, -200); const wider = await json('__gm.song.view()'); p = await json("__gm.song.headAt(0, 'name')"); await t.wheel(p.x, p.y, 200); p = await json('__gm.song.fitAt()'); await click(p.x, p.y); p = await json(`__gm.song.loopAt(${BAR})`); to = await json(`__gm.song.loopAt(${3 * BAR})`); await drag(p.x, p.y, to.x, to.y); const songLoop = (await model()).loop; await press('z', { ctrl: true }); check( 'the wheel zooms the tracks on their heads and the time on the lanes, and the strip above the ruler sets the loop', taller.trackHeight > unzoomed.trackHeight && taller.pxPerTick === unzoomed.pxPerTick && wider.pxPerTick > taller.pxPerTick && wider.trackHeight === taller.trackHeight && songLoop?.start === BAR && songLoop.end === 3 * BAR && (await model()).loop === null, JSON.stringify({ unzoomed, taller, wider, songLoop }), ); // 65g. The heads: mute and solo toggle, the volume slider sets decibels, the menu adds a track. await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); await click(...Object.values(await json("__gm.song.headAt(2, 'solo')"))); p = await json("__gm.song.headAt(2, 'volume', 0.5)"); to = await json("__gm.song.headAt(2, 'volume', 0)"); await drag(p.x, p.y, to.x + 1, to.y); await sleep(150); await addTrackBelowLast(); const heads = await model(); check( 'a head mutes, solos and turns down its track, its menu adds a fifth below and picks it', heads.tracks.length === 5 && heads.tracks[0].mute && heads.tracks[2].solo && heads.tracks[2].volume < -55 && (await json('__gm.song.pickedTrack()')) === heads.tracks[4].id && (await json('__gm.song.info()')) === '5 tracks · 2 items', JSON.stringify(heads.tracks), ); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks[2].pan = 0.5;`); await sleep(100); p = await json("__gm.song.headAt(2, 'volume', 0.2)"); await doubleClick(p.x, p.y); p = await json("__gm.song.headAt(2, 'pan', 0.9)"); await doubleClick(p.x, p.y); await sleep(150); const reset = (await model()).tracks[2]; check( 'a double-click on a slider sets it back to 0: volume to 0 dB, pan to the middle', reset.volume === 0 && reset.pan === 0, JSON.stringify(reset), ); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks[2].volume = -6; song.tracks[2].solo = false; song.tracks[0].mute = false;`); // 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a // clip round and round, each on the bus of its track. p = await json('__gm.song.rulerAt(0)'); await click(p.x, p.y); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.items.find((i) => i.kind === 'sound').length = ${BAR};`); await press('Space'); const played = await waitFor( () => json('{ planned: __gm.song.planned(), clips: __gm.song.clips(), playing: __gm.song.playing() }'), (s) => s.planned.length >= 2 && s.clips.length >= 1, 60, ); const busOf = (track) => `song:${id}:${track}`; const firstClip = played.clips[0]; const notes = played.planned.slice(0, 2); check( 'Space plays the song: the clip at once, the pattern from bar 3 with its notes a second apart, each on its track', played.playing && Boolean(firstClip) && firstClip.sound === SOUND && firstClip.bus === busOf(tracks[0]) && Math.abs(firstClip.duration - 2) < 1e-9 && notes.length === 2 && notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(tracks[2]) && n.tempo === 120) && Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 && Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 && notes[0].note === 60, JSON.stringify(played), ); const buses = await json('__gm.song.buses()'); const bus = (track) => buses.find((b) => b.name === busOf(track)); check( 'each track is set on its bus: the turned-down one at -6 dB, the others at unity', Math.abs(bus(tracks[2])?.gain - 10 ** (-6 / 20)) < 1e-6 && bus(tracks[0])?.gain === 1 && bus(tracks[1])?.gain === 1, JSON.stringify(buses), ); await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); const muted = await waitFor( () => json('__gm.song.buses()'), (list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0, ); check( 'muting a track while it plays silences its bus at once', muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')), JSON.stringify(muted), ); await shot('02-playing'); // 65i. A pattern played in its tab takes over; a double-click on a pattern item opens it there. await evaluate(`__gm.state.openAsset(${JSON.stringify(PATTERN)})`); await sleep(200); p = await json('__gm.pattern.playAt()'); await click(p.x, p.y); await sleep(200); const swapped = await json('{ song: __gm.song.playing(), pattern: __gm.pattern.playing() }'); await click(p.x, p.y); check( 'a pattern started in its tab stops the song', swapped.pattern && !swapped.song, JSON.stringify(swapped), ); // The riff a tone up for four bars on the second track: what its ghost notes (#339) show. await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.items.push({ ...song.items.find((i) => i.kind === 'pattern'), id: 'sg65-tone', track: song.tracks[1].id, tick: 0, length: ${4 * BAR}, offset: 0, transpose: 2 });`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); await doubleClick(p.x, p.y); await sleep(200); const opened = await json('{ tab: __gm.tab(), pattern: __gm.pattern.current() }'); check( 'a double-click on a pattern item opens the pattern in its tab', opened.tab === 'pattern' && opened.pattern === PATTERN, JSON.stringify(opened), ); const songNow = { ...(await model()), name: await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`), }; const riffNow = songNow.items.find((i) => i.id === riff.id); const trackName = (trackId) => songNow.tracks.find((tr) => tr.id === trackId).name; const ghosts = await json('__gm.pattern.ghosts()'); const tone = ghosts.notes.filter((n) => n.track === trackName(songNow.tracks[1].id)); const toneTicks = tone.map((n) => n.tick + ghosts.origin); check( 'the pattern opened from the song shows the other track faint: each note where it sounds, a tone up, on by default', ghosts.toggled && ghosts.enabled && tone.length === 8 && toneTicks.every((tick, i) => tick === Math.floor(i / 2) * BAR + (i % 2) * 960) && tone.every((n, i) => n.note === (i % 2 ? 64 : 60) + 2 - riffNow.transpose && n.pattern === 'Riff 65') && (ghosts.origin - riffNow.tick + (riffNow.offset % BAR)) % BAR === 0 && ghosts.source === `${songNow.name} · ${trackName(riffNow.track)} · bar ${Math.floor(Math.max(ghosts.origin, riffNow.tick) / BAR) + 1}`, JSON.stringify({ ghosts, riff: riffNow }), ); const inside = tone.find((n) => n.tick >= 0 && n.tick < BAR); p = await json(`__gm.pattern.gridAt(${inside.tick + 100}, ${inside.note})`); await mouse('mouseMoved', p.x - 2, p.y); await mouse('mouseMoved', p.x, p.y); const sign = await waitFor( () => json('__gm.tooltip()'), (s) => s !== null, ); check( 'resting on a ghost note tells its track and its pattern', sign?.text === `${trackName(songNow.tracks[1].id)} · Riff 65`, JSON.stringify(sign), ); await shot('03-ghosts'); await click(ghosts.x, ghosts.y); await sleep(150); const hidden = await json('__gm.pattern.ghosts()'); await click(ghosts.x, ghosts.y); await sleep(150); check( 'Ghosts hides them and shows them again', !hidden.toggled && hidden.notes.length === 0 && (await json('__gm.pattern.ghosts()')).notes.length === ghosts.notes.length, JSON.stringify({ hidden: hidden.notes.length, toggled: hidden.toggled }), ); // 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the // loop of its ruler, each track on a bus inside a master of the playing. The pause of the // toolbar holds the clock; S fires Stop song, which fades it out, then it is gone. await evaluate('__gm.sound.stopAll()'); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); song.tracks.forEach((track, i) => Object.assign(track, { mute: false, solo: false, volume: i === 1 ? -6 : 0, pan: i === 1 ? -0.5 : 0 })); const base = song.items[0]; song.items = [ { ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 }, { ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 }, ]; song.loop = { start: ${BAR}, end: ${2 * BAR} };`); await showTab('scene'); const rig = await scaffoldBehavior(t, { assetId: 'sg65-logic', name: 'Music 65', boxId: 'sg65-box', logicId: 'sg65-brain', y: LANES.sound, graph: { nodes: [ node('sg65-start', 'OnStart', 0, 0), node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }), node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }), node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }), ], edges: [ edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'), edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'), ], }, }); const playbacks = () => json('__gm.sound.playbacks()'); await play(); const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60); const [run] = game; const master = `play:${run?.id}`; const gameNotes = run?.planned ?? []; const gameClip = run?.clips[0]; check( 'Play song plays the clip once as the intro, then the pattern round the loop, 2 s a round, on their tracks', game.length === 1 && run.name === `song:${id}` && run.owner !== '' && run.clips.length === 1 && gameClip?.sound === SOUND && gameClip.bus === busOf(tracks[0]) && gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) && Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 && Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 && Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6, JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gameNotes }), ); const gameBuses = await json('__gm.song.buses()'); const gameBus = (name) => gameBuses.find((b) => b.name === name); check( 'the playing has a master at its volume, the tracks inside it as they are set', gameBus(master)?.gain === 0.8 && gameBus(`${master}:${busOf(tracks[0])}`)?.gain === 1 && Math.abs(gameBus(`${master}:${busOf(tracks[1])}`)?.gain - 10 ** (-6 / 20)) < 1e-6 && gameBus(`${master}:${busOf(tracks[1])}`)?.pan === -0.5, JSON.stringify(gameBuses), ); await clickToolbar('pause'); await sleep(400); const heldClock = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); await sleep(300); const heldLater = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); await clickToolbar('pause'); await sleep(200); const resumed = await json('__gm.sound.clockState()'); check( 'the pause of the toolbar holds the clock while the song plays, and resuming lets it run', heldClock.state === 'suspended' && heldLater.state === 'suspended' && heldLater.at === heldClock.at && resumed === 'running', JSON.stringify({ heldClock, heldLater, resumed }), ); await press('s'); const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null); const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40); await sleep(100); const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master)); await stop(); check( 'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone', fading.length === 1 && fading[0].fading !== null && fading[0].playing && fadedOut.length === 0 && leftBuses.length === 0, JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }), ); const left = await rig.remove(); // 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a // track of its own under the one clicked, Space keeps it – WAV up, sound, track and item one // undo step –, Esc throws the next one away. await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); const before = await model(); p = await json("__gm.song.headAt(0, 'name')"); await click(p.x, p.y); p = await json(`__gm.song.rulerAt(${BAR})`); await click(p.x, p.y); p = await json('__gm.song.recordAt()'); await click(p.x, p.y); const growing = await waitFor( () => json('{ view: __gm.song.view(), recording: __gm.song.recording(), button: __gm.song.recordAt().text }'), (s) => s.view.take?.peaks > 120, 60, ); await shot('03-recording'); check( 'Record plays from the cursor and grows the take, red, on a new track under the one clicked', growing.recording && growing.button === 'Stop' && growing.view.playhead !== null && growing.view.take?.index === 1 && growing.view.take.tick === BAR && growing.view.take.length > 0 && !growing.view.take.uploading, JSON.stringify(growing), ); await press('Space'); const kept = await waitFor( () => model(), (m) => m.tracks.length === before.tracks.length + 1, 60, ); const takeTrack = kept.tracks[1]; const takeItem = kept.items.find((i) => i.track === takeTrack?.id); const takeAsset = await json( `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : null; })()`, ); const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`); const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0; check( 'Space keeps it: a WAV sound named after the song, on the track "Recording", from the cursor, as long as it is', kept.tracks[0]?.id === before.tracks[0]?.id && takeTrack?.name === 'Recording' && takeItem?.kind === 'sound' && takeItem.tick === BAR && takeAsset?.type === 'sound' && takeAsset.name === `${songName} – Take 1` && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(takeAsset.file) && takeSeconds > 1 && Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 && !(await json('__gm.song.recording()')) && (await json('__gm.song.view()')).take === null, JSON.stringify({ kept, takeAsset, takeSeconds }), ); await press('z', { ctrl: true }); const undone = await model(); check( 'one Ctrl+Z takes sound, track and item back', undone.tracks.length === before.tracks.length && undone.items.length === before.items.length && (await json( `__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(takeItem?.asset ?? '')})`, )) === false, JSON.stringify(undone), ); const assetCount = await json('__gm.state.project.assets.length'); p = await json('__gm.song.recordAt()'); await click(p.x, p.y); await waitFor( () => json('__gm.song.view()'), (v) => v.take?.peaks > 20, 60, ); await press('Escape'); await sleep(300); const thrown = await json( '{ recording: __gm.song.recording(), take: __gm.song.view().take, playing: __gm.song.playing(), assets: __gm.state.project.assets.length }', ); check( 'Esc while recording throws the take away: nothing is added, playing stops', !thrown.recording && thrown.take === null && !thrown.playing && thrown.assets === assetCount && (await model()).tracks.length === before.tracks.length, JSON.stringify(thrown), ); // 65m. A sound FX on a track (#346). Offline, the shifter a Pitch on a bus becomes moves a tone an // octave and is late by exactly a frame. In the tab, the FX box of a head picks the FX: all of // the track goes through one chain on its bus, the pattern's notes start a frame earlier and // are heard where they were planned, as the clip of the other track is. Stop lets it go. const shifted = await evaluate(` (async () => { const RATE = 48000; const render = async (pitch, input) => { const context = new OfflineAudioContext(2, RATE, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const buffer = context.createBuffer(1, RATE, RATE); buffer.copyToChannel(input, 0); const source = context.createBufferSource(); source.buffer = buffer; const shifter = new AudioWorkletNode(context, 'pitch-shifter', { numberOfInputs: 1, outputChannelCount: [2], channelCount: 2, channelCountMode: 'explicit', parameterData: { pitch }, }); source.connect(shifter).connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(1); }; const tone = new Float32Array(RATE).map((_, i) => Math.sin((2 * Math.PI * 1000 * i) / RATE)); const up = await render(12, tone); let rises = 0; for (let i = RATE / 4 + 1; i < (RATE * 3) / 4; i++) if (up[i - 1] < 0 && up[i] >= 0) rises++; const click = new Float32Array(RATE); click[4800] = 1; const still = await render(0, click); let peak = 0; for (let i = 1; i < RATE; i++) if (Math.abs(still[i]) > Math.abs(still[peak])) peak = i; return { hz: rises * 2, late: peak - 4800, height: still[peak] }; })()`); check( 'the shifter of a bus moves a 1 kHz tone up to 2 kHz, and at 0 semitones is the sound exactly 2048 samples later', Math.abs(shifted.hz - 2000) <= 20 && shifted.late === 2048 && Math.abs(shifted.height - 1) < 1e-4, JSON.stringify(shifted), ); await mutate(` __gm.state.project.assets.push({ id: 'sg65-fx', type: 'soundfx', name: 'Octave 65', props: {}, graph: { nodes: ${JSON.stringify([ node('sg65-fin', 'SoundFxInput', 0, 0), node('sg65-fpitch', 'SoundFxPitch', 200, 0, { semitones: 12 }), node('sg65-fout', 'SoundFxOutput', 400, 0), ])}, edges: ${JSON.stringify([ edge('sg65-fe1', 'sg65-fin', 'out', 'sg65-fpitch', 'in'), edge('sg65-fe2', 'sg65-fpitch', 'out', 'sg65-fout', 'in'), ])}, }, });`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); p = await json("__gm.song.headAt(1, 'fx')"); await click(p.x, p.y); await sleep(150); await clickText('Octave 65'); await sleep(150); const fxTracks = (await model()).tracks; await shot('04-track-fx'); check( "the FX box of a track's head picks a sound FX for that track", fxTracks[1]?.fx === 'sg65-fx' && fxTracks.every((track, i) => i === 1 || track.fx === ''), JSON.stringify(fxTracks.map((track) => track.fx)), ); p = await json('__gm.song.rulerAt(0)'); await click(p.x, p.y); await press('Space'); const throughFx = await waitFor( () => json('{ planned: __gm.song.planned(), clips: __gm.song.clips() }'), (s) => s.planned.length >= 1 && s.clips.length >= 1, 60, ); const rate = await json('__gm.sound.sampleRate()'); const frame = 2048 / rate; const fxBus = (await json('__gm.song.buses()')).find((b) => b.name === busOf(tracks[1])); const [fxNote] = throughFx.planned; const [plainClip] = throughFx.clips; const noteStart = await json(`__gm.sound.startedAt(${fxNote?.voice ?? 0})`); const clipStart = await json(`__gm.sound.startedAt(${plainClip?.voice ?? 0})`); check( 'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time', fxBus?.fx === true && Math.abs(fxBus.latency - frame) < 1e-9 && fxNote?.bus === busOf(tracks[1]) && plainClip?.bus === busOf(tracks[0]) && Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 && Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 && Math.abs(clipStart - plainClip.at) < 1e-6, JSON.stringify({ rate, fxBus, fxNote, plainClip, noteStart, clipStart }), ); await press('Space'); const released = await waitFor( () => json('__gm.song.buses()'), (list) => !list.some((b) => b.name === busOf(tracks[1])), ); check( 'stopping lets the buses of the tab go, the chain on them too', !(await json('__gm.song.playing()')) && !released.some((b) => b.name.startsWith(`song:${id}:`)), JSON.stringify(released), ); // 65j. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(` const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}, 'sg65-fx']); const project = __gm.state.project; project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.state.select(null);`); await showTab('scene'); check( 'the step leaves nothing of its own behind', (await json( `__gm.state.project.assets.filter((a) => a.id.startsWith('sg65') || a.type === 'song').length`, )) === 0 && left.objects === 0 && left.assets === 0, JSON.stringify(left), ); }