import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs'; /** * Sound assets (#45): uploaded like a typeface – picked from what the server holds through * "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing * wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the * node scales the one and can overrule the other in either direction. * * Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness * is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the * witness for a repaint that did not happen. No pixel check could see any of this. * * The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and * a 0.75 s drone, told apart by their exact size rather than by their name. */ const LANE = LANES.sound; const BLIP_BYTES = 4044; const DRONE_BYTES = 12044; export async function sound(t) { const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t; const { select, drag } = t; const assetsOfType = (type) => json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) .map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`); const stats = () => json('__gm.sound.stats()'); /** A voice starts only once its bytes have been decoded, so what it changes is awaited. */ 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; }; // Every reading up to the one `done` takes: a check holds each against its own time on the audio // clock, however late the first one comes in. const trace = async (read, done, tries = 40) => { const seen = [await read()]; for (let i = 0; i < tries && !done(seen.at(-1)); i++) { await sleep(150); seen.push(await read()); } return seen; }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); await showTab('scene'); // 34a. What the server holds, straight from the API: the two files the seed put there. 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); const drone = stored.find((item) => item.bytes === DRONE_BYTES); check( 'the server holds the two seeded sounds, named by their content', Boolean(blip && drone) && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(blip.file), JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }), ); if (!blip || !drone) return; check( 'the listing carries the name each file was uploaded under', blip.name === 'blip.wav' && drone.name === 'drone.wav', JSON.stringify({ blip: blip.name, drone: drone.name }), ); // A file of this run's own that no project uses, for the picker to delete (#344). Random bytes // behind a WAV header: the store reads the signature only, and no earlier run has the same. const spare = await evaluate(`(async () => { const bytes = crypto.getRandomValues(new Uint8Array(48)); bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0); bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8); const r = await fetch('/game-maker/api/index.php?r=sounds&name=spare.wav', { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes, }); return (await r.json()).file; })()`); // 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface. await clickText('+'); await pickAssetType(t, 'Sound'); await sleep(500); const picker = await json('__gm.soundPicker()'); check( 'the + menu opens the sound picker on what the server holds, none of it in the project yet', Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0, JSON.stringify(picker), ); check( 'a row nobody uses yet is captioned with the name the file was uploaded under', picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav', JSON.stringify(picker.captions), ); await shot('34-sound-picker'); // 34b'. A file of one's own that no project uses goes from the picker (#344): the first press of // Delete only asks, the second deletes it on the server. const spareRow = await json(`__gm.soundPickerRow(${JSON.stringify(spare)})`); await click(spareRow.x, spareRow.y); const marked = await json('__gm.soundPicker()'); check( 'a file of one’s own says whose it is and may be deleted', marked.selected === spare && marked.detail.includes('owner e2e') && marked.deleteButton.enabled, JSON.stringify({ selected: marked.selected, spare, detail: marked.detail, button: marked.deleteButton }), ); await click(marked.deleteButton.x, marked.deleteButton.y); const armed = await json('__gm.soundPicker()'); await click(armed.deleteButton.x, armed.deleteButton.y); const emptied = await waitFor( () => json('__gm.soundPicker()'), (p) => !p.files.includes(spare), ); const served = await evaluate(`fetch('/game-maker/data/sounds/${spare}').then((r) => r.status)`); check( 'the first press asks, the second deletes the file on the server', armed.deleteButton.shown === 'Delete for good' && armed.files.includes(spare) && !emptied.files.includes(spare) && emptied.files.includes(blip.file) && emptied.detail === 'Deleted spare.wav.' && served === 404, JSON.stringify({ armed: armed.deleteButton, left: emptied.files.length, detail: emptied.detail, served }), ); // 34c. The ▶ at a row's left is a preview: it plays that file without choosing it. const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`); await click(playAt.x, playAt.y); const previewed = await waitFor(stats, (s) => s.started > 0); const afterPreview = await json('__gm.soundPicker()'); check( 'pressing a row’s ▶ plays that file and picks nothing', previewed.started === 1 && previewed.last?.file === drone.file && afterPreview.played === drone.file && afterPreview.selected === null, JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }), ); const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`); await click(row.x, row.y); await clickText('Use sound'); const sounds = await assetsOfType('sound'); check( 'picking a row makes a sound asset of that file, named after the file rather than "Sound"', sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip', JSON.stringify(sounds), ); if (sounds.length !== 1) return; const soundId = sounds[0].id; check( 'a fresh sound asset is full volume and one-shot', sounds[0].props.volume === 1 && sounds[0].props.loop === false, JSON.stringify(sounds[0].props), ); // Closing the picker silences its preview; nothing it started may outlive the dialog. check( 'closing the picker leaves nothing playing', (await stats()).playing === 0, JSON.stringify(await stats()), ); // 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is. await select({ kind: 'asset', assetId: soundId }); const volumeRow = await json("__gm.inspectorField('Volume', true)"); const loopRow = await json("__gm.inspectorField('Loop', true)"); check( 'a sound asset has a Volume row and a Loop row', Boolean(volumeRow) && Boolean(loopRow), JSON.stringify({ volumeRow, loopRow }), ); await shot('34-sound-asset'); // 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the // asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4; const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(asset, { id: 'sn-logic', name: 'Noisy', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify({ nodes: [ node('sn-start', 'OnStart', 0, 0), node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }), ], edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')], })}, }); p.assets.unshift(asset); ${MAKE_OBJECT} scene.objects.push( make('sn-box', 'crate', '', ${LANE}, 'Noisy box'), make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'), ); __gm.lookAt(0, ${LANE});`); await evaluate('__gm.sound.stopAll()'); const before = await stats(); await play(); const played = await waitFor(stats, (s) => s.started > before.started); check( 'a graph playing a sound reaches the library, at the asset volume scaled by the node’s', played.started === before.started + 1 && played.last?.file === blip.file && Math.abs(played.last.volume - 0.2) < 0.001 && played.last.loop === false, JSON.stringify(played), ); await stop(); // 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive // the world that started it. await mutate(` const p = __gm.state.project; p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`); await play(); const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true); check( 'the asset’s own loop flag reaches the library even though the node never asked for one', looping.playing >= 1 && looping.last?.loop === true, JSON.stringify(looping), ); // Well past the blip's own 0.25 s, so a one-shot would have ended by itself here. await sleep(700); const stillLooping = await stats(); await stop(); const afterStop = await stats(); check( 'a loop keeps playing while the run is on and ends with it', stillLooping.playing >= 1 && afterStop.playing === 0, JSON.stringify({ stillLooping, afterStop }), ); // 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three // and not a checkbox: music has to be playable as a one-shot. const setNodeLoop = (value) => mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`); await setNodeLoop('false'); const beforeOnce = await stats(); await play(); const once = await waitFor(stats, (s) => s.started > beforeOnce.started); check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once)); await stop(); await setNodeLoop(''); // 34g. `Stop sound` silences it from inside the graph, without ending the run. await mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))}); graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))}); graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`); await play(); await waitFor(stats, (s) => s.playing > 0); await press('s'); const silenced = await waitFor(stats, (s) => s.playing === 0); check( 'Stop sound silences a looping sound while the run carries on', silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true, JSON.stringify(silenced), ); await stop(); // 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it, // without any `Stop sound` in the graph. `detached` is the one way out, for the death // sound of an object that destroys itself. await mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push( ${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))}, ${JSON.stringify(node('sn-owner', 'Owner', 260, 620))}, ${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))}, ); graph.edges.push( ${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))}, ${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))}, );`); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); const destroyed = await waitFor(stats, (s) => s.playing === 0); check( 'destroying the object silences the looping sound its behavior was playing', destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true, JSON.stringify(destroyed), ); await stop(); const setDetached = (value) => mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`); await setDetached(true); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); // Long enough that the reaping frame has been and gone – a voice still here now is here on purpose. await sleep(600); const detached = await stats(); check( 'a detached voice is nobody’s and plays on after the object that started it is gone', detached.playing >= 1, JSON.stringify(detached), ); await stop(); check( 'ending the run silences a detached voice too', (await stats()).playing === 0, JSON.stringify(await stats()), ); await setDetached(false); // 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph // from compiling; built into Input → Pitch → Gain → Output, the header plays the sound // through it, and a `Play sound` naming it sends the game's voice the same way. The pitch // comes from a parameter `depth` of the Input: the header has a field for it, and a // `Sound FX parameters` node hands the game's value in. await showTab('scene'); await clickText('+'); await pickAssetType(t, 'Sound FX'); const fxCreated = await json(`{ tab: __gm.tab(), current: __gm.soundfx.current(), info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), assets: __gm.state.project.assets.filter((a) => a.type === 'soundfx').map((a) => a.id), }`); check( 'the + menu creates a sound FX asset and opens its tab, with no Output to compile and no Play', fxCreated.tab === 'soundfx' && fxCreated.assets.length === 1 && fxCreated.current === fxCreated.assets[0] && fxCreated.info === 'No Output node' && fxCreated.preview.sound === soundId && fxCreated.preview.play === false, JSON.stringify(fxCreated), ); const fxId = fxCreated.assets[0]; const fxCanvas = await json('__gm.soundfx.canvasRect()'); await click(fxCanvas.x + 80, fxCanvas.y + 60, 'right'); const fxPalette = await json('__gm.overlayTexts()'); check( 'its palette offers the sound effects and no behavior node', ['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix', 'Delay'].every((label) => fxPalette.includes(label), ) && !fxPalette.includes('Branch'), JSON.stringify(fxPalette.slice(0, 12)), ); await press('Escape'); await mutate(` const fx = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}); fx.name = 'Low'; fx.graph = ${JSON.stringify({ nodes: [ node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]), node('fx-pitch', 'SoundFxPitch', 220, 0), node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }), node('fx-out', 'SoundFxOutput', 660, 0), ], edges: [ edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'), edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'), edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'), edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'), ], })};`); const fxBuilt = await json( '{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }', ); check( 'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default', fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12, JSON.stringify(fxBuilt), ); await evaluate('__gm.sound.stopAll()'); const beforeFx = await stats(); const fxPlayAt = await json('__gm.soundfx.playAt()'); await click(fxPlayAt.x, fxPlayAt.y); const heardFx = await waitFor(stats, (s) => s.started > beforeFx.started); const blipSeconds = await json(`__gm.sound.duration(${JSON.stringify(blip.file)})`); check( 'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage', heardFx.last?.file === blip.file && heardFx.last.pitches.join() === '-12' && heardFx.last.duration === blipSeconds && heardFx.last.stages === 1, JSON.stringify({ ...heardFx, blipSeconds }), ); await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.soundfx.setParam("depth", 5)'); const beforeParam = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started); check( 'the header plays with the value its depth field is set to', heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1, JSON.stringify(heardParam), ); await evaluate('__gm.sound.stopAll()'); // #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))}, ${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))}, ${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))}, );`); const mixInfo = await json('__gm.soundfx.info()'); const beforeMix = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started); check( 'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length', mixInfo === '3 effects' && heardMix.last?.pitches.join() === '5,0' && heardMix.last.stages === 1 && heardMix.last.duration === blipSeconds, JSON.stringify({ mixInfo, ...heardMix }), ); await evaluate('__gm.sound.stopAll()'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix'); graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); // #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come // 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full // (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes // then. Asked to let ring, only the sound stops and the voice stays; a stop after that // cuts it. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify( node('fx-delay', 'SoundFxDelay', 550, 0, { time: 0.3, feedback: 0.5, mix: 0.5, pingPong: true }), )}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e8', 'fx-gain', 'out', 'fx-delay', 'in'))}, ${JSON.stringify(edge('fx-e9', 'fx-delay', 'out', 'fx-out', 'in'))}, );`); const delayInfo = await json('__gm.soundfx.info()'); await evaluate('__gm.sound.stopAll()'); const beforeEcho = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const echoStarted = await waitFor(stats, (s) => s.started > beforeEcho.started); // Timed on the audio clock: the page's can fall behind it (task 523 saw a 2.95 s tail end 1 s in). const audioNow = () => json('({ playing: __gm.sound.stats().playing, clock: __gm.sound.clock() })'); const startedAt = (await audioNow()).clock; const echoLevels = await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`); const ringing = await waitFor(audioNow, (s) => s.clock - startedAt >= 1); const quiet = await waitFor(audioNow, (s) => s.playing === 0, 40); const rangFor = quiet.clock - startedAt; const echoKnobs = echoLevels?.[0]?.slice(2) ?? []; check( 'a Delay keeps the voice after its sound has ended, until its echoes have faded', delayInfo === '3 effects' && echoStarted.last?.stages === 2 && echoKnobs.length === 5 && Math.abs(echoKnobs[0] - 0.3) < 1e-6 && Math.abs(echoKnobs[1] - 0.5) < 1e-6 && Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 && Math.abs(echoKnobs[3] - 1) < 1e-6 && Math.abs(echoKnobs[4] - 1) < 1e-6 && ringing.playing === 1 && ringing.clock - startedAt < 2.4 && quiet.playing === 0 && rangFor > 2.4 && rangFor < 4.5, JSON.stringify({ delayInfo, last: echoStarted.last, echoLevels, ringing, startedAt, rangFor }), ); await click(fxPlayAt.x, fxPlayAt.y); await waitFor(stats, (s) => s.playing === 1); const beforeLetRing = await stats(); await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)}, true)`); await sleep(500); const letRing = await stats(); await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)})`); const cut = await stats(); check( 'Let ring stops the sound and keeps the voice for its echoes; a stop after that cuts it', letRing.playing === 1 && letRing.stopped === beforeLetRing.stopped + 1 && cut.playing === 0 && cut.stopped === beforeLetRing.stopped + 2, JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }), ); // #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`); const bypassed = await json( "({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })", ); const beforeBypass = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started); await evaluate('__gm.sound.stopAll()'); check( 'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain', bypassed.alpha < 0.5 && bypassed.gain === 1 && bypassed.settings?.includes('Bypass: true') && heardBypass.last?.stages === 1, JSON.stringify({ bypassed, last: heardBypass.last }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay'); graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); // #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the // curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the // voice passes one filter for it. // #370: the editor is a tab of its own, and goes with its node. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e10', 'fx-gain', 'out', 'fx-eq', 'in'))}, ${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))}, );`); const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')"); await t.doubleClick(eqNode.x, eqNode.y); const eqShown = await json('__gm.eq.shown()'); const eqDocs = await json('__gm.documents()'); const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)'); await t.doubleClick(eqPeakAt.x, eqPeakAt.y); const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }'); const bandNear = (band, hz, db) => band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5; check( 'a double-click opens the Parametric EQ in a tab, and one on its curve adds a peak there', eqShown?.nodeId === 'fx-eq' && eqDocs.active === `eq:${fxId}:fx-eq` && eqDocs.docs.includes(`asset:${fxId}`) && eqAdded.bands.length === 1 && eqAdded.bands[0].type === 'peaking' && bandNear(eqAdded.bands[0], 1000, 6) && eqAdded.fields.type === 'Peaking' && eqAdded.fields.slope === null, JSON.stringify({ eqShown, eqDocs, eqAdded }), ); const eqHandle = await json('__gm.eq.handleAt(0)'); const eqTo = await json('__gm.eq.pointAt(4000, -6)'); await drag(eqHandle.x, eqHandle.y, eqTo.x, eqTo.y); const eqDragged = (await json('__gm.eq.bands()'))[0]; await press('z', { ctrl: true }); const eqUndone = (await json('__gm.eq.bands()'))[0]; await press('z', { ctrl: true, shift: true }); const eqHandleBack = await json('__gm.eq.handleAt(0)'); await t.wheel(eqHandleBack.x, eqHandleBack.y, -100); const eqWheeled = (await json('__gm.eq.bands()'))[0]; check( 'dragging the band moves it as one undo step, and the wheel narrows it', bandNear(eqDragged, 4000, -6) && bandNear(eqUndone, 1000, 6) && bandNear(eqWheeled, 4000, -6) && eqWheeled.q > 1, JSON.stringify({ eqDragged, eqUndone, eqWheeled }), ); await shot('34-parametric-eq'); await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); await evaluate('__gm.sound.stopAll()'); const eqBefore = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started); await evaluate('__gm.sound.stopAll()'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-eq'); graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); const eqDocsAfter = await json('__gm.documents()'); check( 'the voice passes the gain and the band, and the EQ’s tab goes with its node', eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')), JSON.stringify({ last: eqHeard.last, eqDocsAfter }), ); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: 'false', fx: ${JSON.stringify(fxId)} });`); const beforeGameFx = await stats(); await play(); const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started); check( 'a Play sound naming the asset plays the game’s voice through it', gameFx.last?.pitches.join() === '-12' && gameFx.last.stages === 1, JSON.stringify(gameFx), ); await stop(); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify( node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [ { key: 'depth', kind: 'number' }, ]), )}); graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`); const beforeGameParam = await stats(); await play(); const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started); check( 'a Sound FX parameters node hands depth 7 to the game’s voice', gameParam.last?.pitches.join() === '7' && gameParam.last.stages === 1, JSON.stringify(gameParam), ); await stop(); // #315: two looping voices of the same sound start at depth 7, the first keeps its number in a // variable, and `Set sound FX parameters` hands that one voice depth 3 every frame. In a // real AudioContext the first one's pitch – the AudioParam of its worklet (#317), in // semitones – glides there, the second one's stays put. const tuneIds = ['sn-keep', 'sn-play2', 'sn-tick', 'sn-read', 'sn-tune', 'sn-fxp2']; const tuneEdges = [ 'sn-e-keep', 'sn-e-voice', 'sn-e-play2', 'sn-e-fxp3', 'sn-e-tick', 'sn-e-read', 'sn-e-fxp2', ]; await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); logic.variables = [${JSON.stringify(variable('voice', 'number', 0))}]; const graph = logic.graph; const play = graph.nodes.find((n) => n.id === 'sn-play'); play.params.loop = 'true'; graph.nodes.push( ${JSON.stringify(node('sn-keep', 'Variables', 520, 0, {}, [written('voice', 'number')]))}, { ...structuredClone(play), id: 'sn-play2', x: 780 }, ${JSON.stringify(node('sn-tick', 'OnUpdate', 0, 300))}, ${JSON.stringify(node('sn-read', 'Variables', 0, 420, {}, [port('voice', 'number')]))}, ${JSON.stringify(node('sn-tune', 'SetSoundFxParameters', 260, 300))}, ${JSON.stringify( node('sn-fxp2', 'SoundFxParameters', 0, 540, { soundFxAsset: fxId, depth: 3 }, [ { key: 'depth', kind: 'number' }, ]), )}, ); graph.edges.push( ${JSON.stringify(edge('sn-e-keep', 'sn-play', 'exec', 'sn-keep', 'exec'))}, ${JSON.stringify(edge('sn-e-voice', 'sn-play', 'voice', 'sn-keep', 'voice'))}, ${JSON.stringify(edge('sn-e-play2', 'sn-keep', 'exec', 'sn-play2', 'exec'))}, ${JSON.stringify(edge('sn-e-fxp3', 'sn-fxp', 'params', 'sn-play2', 'fxParams'))}, ${JSON.stringify(edge('sn-e-tick', 'sn-tick', 'exec', 'sn-tune', 'exec'))}, ${JSON.stringify(edge('sn-e-read', 'sn-read', 'voice', 'sn-tune', 'voice'))}, ${JSON.stringify(edge('sn-e-fxp2', 'sn-fxp2', 'params', 'sn-tune', 'fxParams'))}, );`); const beforeTune = await stats(); await play(); const tunedStats = await waitFor( stats, (s) => s.tuned > beforeTune.tuned && s.started >= beforeTune.started + 2, ); const glided = (levels) => Array.isArray(levels) && levels.length === 2 && Math.abs(levels[0][0] - 3) < 0.05; const levels = await waitFor(() => json(`__gm.sound.levels(${JSON.stringify(blip.file)})`), glided); check( 'Set sound FX parameters moves the one voice whose number it holds from depth 7 to 3, not the other', tunedStats.last?.pitches.join() === '7' && tunedStats.last.loop === true && tunedStats.worklet === true && glided(levels) && Math.abs(levels[0][1] - 10 ** (-6 / 20)) < 0.001 && levels[1][0] === 7, JSON.stringify({ last: tunedStats.last, tuned: tunedStats.tuned, worklet: tunedStats.worklet, levels }), ); await stop(); // A `time` of 2 s takes the same voice from depth 7 to -5 evenly on the audio clock, 6 semitones // a second: half a second in it is still well above the end, where a quick glide would be there. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-tune').params.time = 2; graph.nodes.find((n) => n.id === 'sn-fxp2').params.depth = -5;`); await play(); const pitch = async () => (await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`))?.[0]?.[0]; const early = await waitFor(pitch, (s) => typeof s === 'number' && s < 6.9); const later = await waitFor(pitch, (s) => typeof s === 'number' && s < early - 0.6); await new Promise((resolve) => setTimeout(resolve, 500)); const afterHalf = await pitch(); check( 'Set sound FX parameters with a time ramps the pitch over that time instead of jumping', early > -1.8 && later > -3.3 && afterHalf > -4.3 && afterHalf < later, JSON.stringify({ early, later, afterHalf }), ); await stop(); // The easings of an animation key: Quad in hands the clock a curve, 7 - 3 * t^2 towards -5 in 2 s, // where linear is 7 - 6 * t. Each reading is timed on the audio clock from the voice's start, so a // first one that comes in late is held against where the curve is then. Between 0.3 and 1.7 s the // curve is more than a semitone above linear, and it never falls below it. await mutate(` const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune'); Object.assign(tune.params, { ease: 'quad', direction: 'in' });`); await play(); const pitchNow = () => json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`); const sinceStart = async (seen, since = 'start') => { const now = await stats(); const from = since === 'tune' ? now.tunedAt : now.last.at; return seen .filter((s) => typeof s.pitch === 'number') .map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) })); }; const sweep = await sinceStart(await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < -4.3)); const midway = sweep.filter(({ t }) => t > 0.3 && t < 1.7); check( 'Set sound FX parameters with Quad in ramps slowly first, then on to the end', midway.length > 0 && midway.every(({ t, pitch }) => pitch > 7 - 6 * t + 1) && sweep.every(({ t, pitch }) => pitch > 7 - 6 * t - 0.1) && sweep.at(-1).pitch < -4.3, JSON.stringify(sweep), ); await stop(); // Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the // audio clock is still 7, the first one past it is the end. Timed from the change, not from the // start of the voice: task 529 saw it arrive 80 ms later and the 7 held, rightly, to 2.08 s. await mutate(` const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune'); Object.assign(tune.params, { ease: 'step', time: 2 });`); await play(); const steps = await sinceStart( await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6), 'tune', ); const held = steps.filter(({ pitch }) => pitch === 7); const jumped = steps.find(({ pitch }) => pitch < 6); check( 'Set sound FX parameters with Step holds the pitch for the time, then jumps to the end', held.length > 0 && held.every(({ t }) => t < 2.05) && jumped !== undefined && jumped.t > 1.95 && Math.abs(jumped.pitch + 5) < 0.001, JSON.stringify(steps), ); await stop(); // #317: the worklet itself, rendered in an offline context of this browser – what a muted check // can hear. A second of 440 Hz an octave up is 880 Hz and still a second long; a sweep from // 0 to -12 over it keeps that second too. Then 16 stereo voices at +5 for 2 s, timed, for // how many this machine plays at once. const offline = await evaluate(` (async () => { const RATE = 48000; const render = async (voices, seconds, channels, shape) => { const context = new OfflineAudioContext(channels, RATE * seconds, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const tone = new Float32Array(RATE * seconds); for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.5; for (let v = 0; v < voices; v++) { const node = new AudioWorkletNode(context, 'pitch-player', { numberOfInputs: 0, outputChannelCount: [channels], parameterData: { pitch: shape.from }, processorOptions: { file: 'probe', channels: v === 0 ? Array(channels).fill(tone) : undefined, step: 1, loop: false }, }); if (shape.to !== shape.from) node.parameters.get('pitch').setValueAtTime(shape.from, 0).linearRampToValueAtTime(shape.to, seconds); node.connect(context.destination); } const started = performance.now(); const out = (await context.startRendering()).getChannelData(0); return { out, ms: performance.now() - started }; }; const rises = (out, from, to) => { let count = 0; for (let i = from + 1; i < to; i++) if (out[i - 1] < 0 && out[i] >= 0) count++; return count / ((to - from) / RATE); }; const heard = (out) => { let last = out.length; while (last > 0 && Math.abs(out[last - 1]) <= 1e-4) last--; return last; }; const up = await render(1, 1, 1, { from: 12, to: 12 }); const sweep = await render(1, 1, 1, { from: 0, to: -12 }); const load = await render(16, 2, 2, { from: 5, to: 5 }); return { upHz: rises(up.out, 12000, 36000), upLength: heard(up.out), sweepEarlyHz: rises(sweep.out, 6000, 12000), sweepLateHz: rises(sweep.out, 36000, 42000), sweepLength: heard(sweep.out), voices: 16, renderMs: Math.round(load.ms), realTimeVoices: Math.round((16 * 2000) / load.ms), }; })()`); check( 'the pitch worklet plays an octave up at the length of the sound, and a sweep down keeps it too', Math.abs(offline.upHz / 880 - 1) < 0.02 && offline.upLength > 48000 - 100 && offline.sweepEarlyHz > offline.sweepLateHz * 1.3 && offline.sweepLength > 48000 - 100, JSON.stringify(offline), ); // #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s, // feedback 0.5, mix 0.25) comes back left at 0.3 s at half its level, right at 0.6 s at // half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then // 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324). const echo = await evaluate(` (async () => { const RATE = 48000; const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong }); const render = async (voices, seconds, stage = echoOf(true)) => { const context = new OfflineAudioContext(2, RATE * seconds, RATE); const burst = context.createBuffer(1, RATE * 0.01, RATE); burst.getChannelData(0).fill(0.5); for (let v = 0; v < voices; v++) { const source = context.createBufferSource(); source.buffer = burst; const nodes = __gm.sound.stageNodes(context, stage); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); } const started = performance.now(); const out = await context.startRendering(); return { left: out.getChannelData(0), right: out.getChannelData(1), ms: performance.now() - started }; }; // The middle of the burst: its edges ring a little through the damping. const peak = (data, at) => { let sum = 0; const from = Math.round((at + 0.003) * RATE); const to = Math.round((at + 0.007) * RATE); for (let i = from; i < to; i++) sum += Math.abs(data[i]); return Math.round((sum / (to - from)) * 1000) / 1000; }; // Samples from where each echo is due to where it is heard, in both kinds of echo. const late = ({ left, right }) => { const found = []; for (let k = 1; k <= 4; k++) { const from = Math.round((k * 0.3 - 0.05) * RATE); let at = from; while (at < from + RATE * 0.1 && Math.abs(left[at]) <= 0.002 && Math.abs(right[at]) <= 0.002) at++; found.push(at - Math.round(k * 0.3 * RATE)); } return found; }; const one = await render(1, 1.3); const plain = await render(1, 1.3, echoOf(false)); const load = await render(64, 2); const lateBy = late(one); const at = (k) => k * 0.3 + lateBy[k - 1] / RATE; return { late: lateBy, latePlain: late(plain), dry: [peak(one.left, 0), peak(one.right, 0)], first: [peak(one.left, at(1)), peak(one.right, at(1))], second: [peak(one.left, at(2)), peak(one.right, at(2))], third: [peak(one.left, at(3)), peak(one.right, at(3))], between: [peak(one.left, 0.45), peak(one.right, 0.45)], voices: 64, renderMs: Math.round(load.ms), realTimeVoices: Math.round((64 * 2000) / load.ms), }; })()`); const near = (value, to) => Math.abs(value - to) < 0.02; check( 'the player’s ping-pong echo comes back left, then right, then left, each round half as loud', near(echo.dry[0], 0.5) && near(echo.dry[1], 0.5) && near(echo.first[0], 0.25) && echo.first[1] < 0.005 && echo.second[0] < 0.005 && near(echo.second[1], 0.125) && near(echo.third[0], 0.0625) && echo.third[1] < 0.005 && echo.between.every((v) => v < 0.005) && // Chrome takes a render quantum more for every round of a feedback loop. [echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)), JSON.stringify(echo), ); // #322: the Bitcrusher on the worklet, rendered offline. A 440 Hz sine at 0.8 through 2 bits at // 4 kHz holds every value for 12 samples and leaves only steps of 0.5. 16 bits at 48 kHz // leave the sound as it is, a mix of 0.5 is half of each. Then 64 stereo voices for 2 s, // timed, for how many this machine crushes at once. const crushed = await evaluate(` (async () => { const RATE = 48000; const render = async (voices, seconds, channels, stage) => { const context = new OfflineAudioContext(channels, RATE * seconds, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const tone = context.createBuffer(channels, RATE * seconds, RATE); for (let c = 0; c < channels; c++) { const data = tone.getChannelData(c); for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.8; } for (let v = 0; v < voices; v++) { const source = context.createBufferSource(); source.buffer = tone; const nodes = __gm.sound.stageNodes(context, { kind: 'crush', ...stage }); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); } const started = performance.now(); const out = (await context.startRendering()).getChannelData(0); return { out, tone: tone.getChannelData(0), ms: performance.now() - started }; }; const crush = await render(1, 0.5, 1, { bits: 2, rate: 4000, mix: 1 }); let offStep = 0; let unheld = 0; for (let i = 0; i < crush.out.length; i++) { offStep = Math.max(offStep, Math.abs(crush.out[i] * 2 - Math.round(crush.out[i] * 2))); if (crush.out[i] !== crush.out[i - (i % 12)]) unheld++; } const levels = [...new Set(crush.out)].sort((a, b) => a - b); const clean = await render(1, 0.5, 1, { bits: 16, rate: 48000, mix: 1 }); const half = await render(1, 0.5, 1, { bits: 1, rate: 48000, mix: 0.5 }); let cleanOff = 0; let halfOff = 0; for (let i = 0; i < clean.out.length; i++) { cleanOff = Math.max(cleanOff, Math.abs(clean.out[i] - clean.tone[i])); halfOff = Math.max(halfOff, Math.abs(half.out[i] - (half.tone[i] + Math.round(half.tone[i])) / 2)); } const load = await render(64, 2, 2, { bits: 6, rate: 11025, mix: 0.8 }); return { levels, offStep, unheld, cleanOff, halfOff, voices: 64, renderMs: Math.round(load.ms), realTimeVoices: Math.round((64 * 2000) / load.ms), }; })()`); check( 'the Bitcrusher holds the sound at its rate and rounds it to its bits, and 16 bits at 48 kHz leave it as it is', JSON.stringify(crushed.levels) === JSON.stringify([-1, -0.5, 0, 0.5, 1]) && crushed.offStep < 1e-6 && crushed.unheld === 0 && crushed.cleanOff < 2 ** -15 && crushed.halfOff < 1e-6, JSON.stringify(crushed), ); // #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser // at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second, // the notch passes it and is two octaves off between, where cos(28°) of it is left. A // Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples // at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples // later. Then 64 stereo voices of each for 2 s, timed. const swirled = await evaluate(` (async () => { const RATE = 48000; const render = async (voices, seconds, channels, stage, fill, at = 0) => { const context = new OfflineAudioContext(channels, RATE * seconds, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const sound = context.createBuffer(channels, RATE * seconds, RATE); for (let c = 0; c < channels; c++) fill(sound.getChannelData(c)); for (let v = 0; v < voices; v++) { const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, stage, at); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); } const started = performance.now(); const out = (await context.startRendering()).getChannelData(0); return { out, ms: performance.now() - started }; }; const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8; }; const rms = (out, from, to) => { let sum = 0; for (let i = from; i < to; i++) sum += out[i] ** 2; return Math.sqrt(sum / (to - from)); }; const phaser = (stages, rate, depth) => ({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 }); const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out; const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out; const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out; const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200)); const flanger = (offset, sweep) => ({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 }); const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; }; const heard = (out, from) => { const found = []; for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]); return found; }; const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE)); const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE)); const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; }; const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise); const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise); return { notch: +rms(notch, 4800, notch.length).toFixed(4), peak: +rms(peak, 4800, peak.length).toFixed(4), sweptLow: +Math.min(...windows).toFixed(3), sweptHigh: +Math.max(...windows).toFixed(3), still, sweeping, phaserVoices: Math.round((64 * 2000) / phaserLoad.ms), flangerVoices: Math.round((64 * 2000) / flangerLoad.ms), }; })()`); check( 'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice', swirled.notch < 0.01 && Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 && swirled.sweptLow < 0.1 && swirled.sweptHigh > 0.46 && swirled.sweptHigh < 0.51 && JSON.stringify(swirled.still) === JSON.stringify([ [0, 0.5], [48, 0.5], ]) && swirled.sweeping[0]?.[1] === 0.5 && JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]), JSON.stringify(swirled), ); // #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump // falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at // 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out. const filtered = await evaluate(` (async () => { const RATE = 48000; const level = async (hz, stage) => { const context = new OfflineAudioContext(1, RATE / 2, RATE); const tone = context.createOscillator(); tone.frequency.value = hz; const half = context.createGain(); half.gain.value = 0.5; const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage }); tone.connect(half).connect(nodes.input); nodes.output.connect(context.destination); tone.start(); const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4); let sum = 0; for (const v of out) sum += v * v; return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2); }; return { low12: await level(2000, { filter: 'lowpass', q: -3.0103 }), low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }), peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }), notch: await level(1000, { filter: 'notch', q: 1 }), }; })()`); check( 'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out', Math.abs(filtered.low12 + 12.3) < 0.2 && Math.abs(filtered.low48 + 49.2) < 0.8 && Math.abs(filtered.peak - 6) < 0.1 && filtered.notch < -40, JSON.stringify(filtered), ); // #319: Pan rendered offline. A mono sine at 0.5 all to the left leaves the right silent, in the // middle each side gets 0.707 of it. A stereo sound panned half right keeps its right side // and moves part of its left into it, as Web Audio pans stereo: left 0.707, right 1 + 0.707. const panned = await evaluate(` (async () => { const RATE = 48000; const peaks = async (pan, channels) => { const context = new OfflineAudioContext(2, RATE / 10, RATE); const sound = context.createBuffer(channels, RATE / 10, RATE); for (let c = 0; c < channels; c++) { const data = sound.getChannelData(c); for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.5; } const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, { kind: 'pan', pan }); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); const out = await context.startRendering(); return [0, 1].map((c) => +Math.max(...out.getChannelData(c).map(Math.abs)).toFixed(3)); }; return { left: await peaks(-1, 1), middle: await peaks(0, 1), balance: await peaks(0.5, 2) }; })()`); check( 'Pan moves a mono sound between the sides, and a stereo one towards the side it goes to', JSON.stringify(panned) === JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }), JSON.stringify(panned), ); // #326: Compressor and Limiter rendered offline. An impulse comes out of the Compressor 288 // samples late (the spec's 6 ms) and out of the Limiter 240 (5 ms). A 1 kHz sine at full // scale through a hard-knee Compressor at -12 dB, 4:1 with full auto-gain comes back near // full scale; with no auto-gain and no makeup it is 9 dB down. A sine at 2 through a // Limiter at -6 dB never goes past it, with auto-gain 1 it sits at the ceiling of -1 dB. const dynamics = await evaluate(` (async () => { const RATE = 48000; const render = async (stage, fill) => { const context = new OfflineAudioContext(1, RATE / 2, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const sound = context.createBuffer(1, RATE / 2, RATE); fill(sound.getChannelData(0)); const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, stage); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(0); }; const impulse = (data) => { data[480] = 0.01; }; const sine = (amplitude) => (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * amplitude; }; const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; }; const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2); const compress = (gain) => ({ kind: 'compress', threshold: -12, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain }); const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain }); const limited = await render(limit(0), sine(2)); return { compressorLate: loudest(await render(compress(1), impulse)) - 480, limiterLate: loudest(await render(limit(0), impulse)) - 480, autoGain: db(await render(compress(10 ** (3.6 / 20)), sine(1))), noMakeup: db(await render(compress(10 ** (-5.4 / 20)), sine(1))), limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3), ceiling: db(await render(limit(1), sine(2))), }; })()`); check( 'a Compressor is 6 ms late and auto-gain brings full scale back, a Limiter is 5 ms late and never passes its limit', dynamics.compressorLate === 288 && dynamics.limiterLate === 240 && Math.abs(dynamics.autoGain) < 0.5 && Math.abs(dynamics.noMakeup + 9) < 0.5 && dynamics.limitedPeak <= -6 + 1e-3 && dynamics.ceiling <= -1 + 1e-3 && dynamics.ceiling > -1.2, JSON.stringify(dynamics), ); // #323: Saturation rendered offline. An impulse through it comes out 192 samples late at 48 and // at 44.1 kHz, the oversampling's delay. Half bent and half dry at 1125 Hz – where 192 // samples off would cancel – the two meet in step and the sine stays as loud. A sine at // -12 dBFS driven 24 dB into tanh comes back as loud with full auto-gain. const saturated = await evaluate(` (async () => { const render = async (rate, stage, fill) => { const context = new OfflineAudioContext(1, rate / 2, rate); const sound = context.createBuffer(1, rate / 2, rate); fill(sound.getChannelData(0), rate); const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, { kind: 'saturate', curve: 'hard', drive: 0, tone: 20000, level: 1, mix: 1, ...stage }); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(0); }; const impulse = (data) => { data[480] = 0.5; }; const sine = (hz, amplitude) => (data, rate) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * hz * i) / rate) * amplitude; }; const loudest = (out) => out.reduce((at, v, i) => (Math.abs(v) > Math.abs(out[at]) ? i : at), 0); const db = (out, amplitude) => { let sum = 0; const from = out.length / 2; for (let i = from; i < out.length; i++) sum += out[i] ** 2; return +(20 * Math.log10(Math.sqrt(sum / (out.length - from)) / (amplitude * Math.SQRT1_2))).toFixed(2); }; return { late48: loudest(await render(48000, {}, impulse)) - 480, late44: loudest(await render(44100, {}, impulse)) - 480, halfDry: db(await render(48000, { mix: 0.5 }, sine(1125, 0.5)), 0.5), autoGain: db(await render(48000, { curve: 'soft', drive: 24, level: 10 ** (__gm.sound.saturationAutoGain('soft', 24) / 20) }, sine(1000, 0.25)), 0.25), }; })()`); check( 'a Saturation is 192 samples late at either rate, its dry half meets the bent one in step, auto-gain keeps it as loud', saturated.late48 === 192 && saturated.late44 === 192 && Math.abs(saturated.halfDry) < 0.1 && Math.abs(saturated.autoGain) < 0.5, JSON.stringify(saturated), ); // #320: Tremolo and Vibrato on the worklet, rendered offline. A Tremolo square at 10 Hz, depth 1, // on a steady 0.5 for a voice starting at 0.1 s: silence before it, then 0.5 for 50 ms and // 0 for 50 ms. A Vibrato square at 5 Hz, one semitone deep, on a 1 kHz sine: the first // half of a period at 1059.5 Hz, the second at 940.5 Hz. const swung = await evaluate(` (async () => { const RATE = 48000; const render = async (stage, fill, at = 0) => { const context = new OfflineAudioContext(1, RATE, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const sound = context.createBuffer(1, RATE, RATE); fill(sound.getChannelData(0)); const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, stage, at); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(0); }; const steady = (data) => data.fill(0.5); const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE); }; const levels = (out, from, to) => [...new Set(out.subarray(from, to))].map((v) => +v.toFixed(3)); const tremolo = await render({ kind: 'tremolo', shape: 'square', rate: 10, depth: 1 }, steady, 0.1); const hz = (out, from, to) => { const at = []; for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++) if (out[i - 1] < 0 && out[i] >= 0) at.push(i - out[i] / (out[i] - out[i - 1])); return +(((at.length - 1) * RATE) / (at.at(-1) - at[0])).toFixed(1); }; const vibrato = await render({ kind: 'vibrato', shape: 'square', rate: 5, depth: 1 }, sine); return { before: levels(tremolo, 0, 4800), on: levels(tremolo, 4810, 7190), off: levels(tremolo, 7210, 9590), up: hz(vibrato, 0.41, 0.49), down: hz(vibrato, 0.51, 0.59), }; })()`); check( 'a Tremolo starts its swing with its voice, a Vibrato jumps a semitone up and as far down', JSON.stringify([swung.before, swung.on, swung.off]) === JSON.stringify([[0], [0.5], [0]]) && Math.abs(swung.up - 1059.5) < 1 && Math.abs(swung.down - 940.5) < 1, JSON.stringify(swung), ); // #328: Reverb rendered offline. An impulse at 0.05 s through a Custom reverb of 1 s with a // pre-delay of 50 ms: nothing of it before 0.1 s, then it falls by 60 dB a second, 36 dB // from 0.2-0.3 s to 0.8-0.9 s after it. At mix 0.5 the impulse itself is there at half. // With a sound as its impulse response – one sample at 0.2 s – the impulse comes back // 0.2 s and the pre-delay later, and nowhere else. const reverbed = await evaluate(` (async () => { const RATE = 48000; const render = async (stage, impulse) => { const context = new OfflineAudioContext(1, RATE * 1.5, RATE); const sound = context.createBuffer(1, RATE * 1.5, RATE); sound.getChannelData(0)[RATE * 0.05] = 0.5; let ir; if (impulse) { ir = context.createBuffer(2, RATE / 2, RATE); for (let c = 0; c < 2; c++) ir.getChannelData(c)[RATE * impulse] = 1; } const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, { kind: 'reverb', decay: 1, damping: 0, predelay: 0.05, mix: 1, ...stage }, 0, ir); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(0); }; const db = (out, from, to) => { let sum = 0; for (let i = from * RATE; i < to * RATE; i++) sum += out[i] ** 2; return 10 * Math.log10(sum / ((to - from) * RATE)); }; const peak = (out, from, to) => Math.max(...out.subarray(from * RATE, to * RATE).map(Math.abs)); const wet = await render({}); const half = await render({ mix: 0.5 }); const sounded = await render({ impulse: 'room.wav' }, 0.2); const heard = []; sounded.forEach((v, i) => { if (Math.abs(v) > 1e-6) heard.push(i); }); return { before: peak(wet, 0, 0.0999), fall: +(db(wet, 0.25, 0.35) - db(wet, 0.85, 0.95)).toFixed(1), dry: +half[RATE * 0.05].toFixed(4), sound: [heard[0], heard.at(-1)], }; })()`); check( 'a Reverb waits its pre-delay, falls 60 dB in its decay, and takes a sound as its impulse response', reverbed.before === 0 && Math.abs(reverbed.fall - 36) < 3 && reverbed.dry === 0.25 && JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]), JSON.stringify(reverbed), ); // #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of // 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75 // the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended // half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it // came in is held back to meet it. const wetted = await evaluate(` (async () => { const RATE = 48000; const render = async (stage, fill) => { const context = new OfflineAudioContext(1, RATE / 2, RATE); const sound = context.createBuffer(1, RATE / 2, RATE); fill(sound.getChannelData(0)); const source = context.createBufferSource(); source.buffer = sound; const nodes = __gm.sound.stageNodes(context, stage); source.connect(nodes.input); nodes.output.connect(context.destination); source.start(); return (await context.startRendering()).getChannelData(0); }; const steady = (data) => data.fill(0.5); const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; }; const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3); const delay = async (mix) => { const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady); return [at(out, 0.05), at(out, 0.2)]; }; const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 }; const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine); let sum = 0; for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2; return { gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1), quarter: await delay(0.25), threeQuarters: await delay(0.75), echoOnly: await delay(1), compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2), }; })()`); check( 'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step', wetted.gain === 0.375 && JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) === JSON.stringify([ [0.5, 0.75], [0.25, 0.75], [0, 0.5], ]) && Math.abs(wetted.compressed) < 0.1, JSON.stringify(wetted), ); // #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty // of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a // quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of // 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed. const chipped = await evaluate(` (async () => { const RATE = 48000; const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0, vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 }; const render = async (voices, seconds, params, options) => { const context = new OfflineAudioContext(1, RATE * seconds, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const said = []; for (let v = 0; v < voices; v++) { const node = new AudioWorkletNode(context, 'chip-player', { numberOfInputs: 0, outputChannelCount: [1], parameterData: params, processorOptions: { ...shape, ...options }, }); node.port.onmessage = (event) => said.push(event.data); node.connect(context.destination); } const started = performance.now(); const out = (await context.startRendering()).getChannelData(0); const ms = performance.now() - started; await new Promise((resolve) => setTimeout(resolve, 100)); return { out, ms, said }; }; const cycles = (out, from, to) => { let n = 0; for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++; return n; }; const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 }); const heard = tone.out.subarray(0.25 * RATE); const firstHeard = tone.out.findIndex((v) => v !== 0); const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 }); const lastHeard = slid.out.findLastIndex((v) => v !== 0); const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 }); return { firstHeard, periods: cycles(tone.out, 0.25, 1), up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000, peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0), lastHeard: lastHeard / RATE, said: slid.said, voices: 64, renderMs: Math.round(load.ms), realTimeVoices: Math.round((64 * 2000) / load.ms), }; })()`); check( 'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency', chipped.firstHeard === 0.25 * 48000 && Math.abs(chipped.periods - 330) <= 1 && Math.abs(chipped.up - 0.25) < 0.01 && Math.abs(chipped.peak - 0.5) < 0.01 && Math.abs(chipped.lastHeard - 1) < 0.01 && chipped.said.join() === 'ended', JSON.stringify(chipped), ); // A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each // share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full. const atCenter = await evaluate(` (async () => { const RATE = 48000; const render = async (mix, glide) => { const context = new OfflineAudioContext(2, RATE, RATE); const merger = context.createChannelMerger(2); merger.connect(context.destination); const shareA = context.createGain(); const shareB = context.createGain(); shareA.gain.value = 0; shareB.gain.value = 0; const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB); for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) { const steady = context.createConstantSource(); steady.offset.value = level; steady.connect(share).connect(merger, 0, side); steady.start(); } if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1); driver.start(); const out = await context.startRendering(); const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2]; return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5); }; const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000); const fixed = {}; for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false)); return { fixed, glide: (await render(0, true)).map(round) }; })()`); const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b); check( 'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides', pairNear(atCenter.fixed[0], 1, 0) && pairNear(atCenter.fixed[0.25], 1, 0.5) && pairNear(atCenter.fixed[0.5], 1, 1) && pairNear(atCenter.fixed[0.75], 0.5, 1) && pairNear(atCenter.fixed[1], 0, 1) && pairNear(atCenter.glide[0], 1, 0.5) && pairNear(atCenter.glide[1], 1, 1) && pairNear(atCenter.glide[2], 0.5, 1), JSON.stringify(atCenter), ); await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); logic.variables = []; const graph = logic.graph; graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id)); graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp'); graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp'); Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' }); const project = __gm.state.project; project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`); // 34g-5. An instrument (#331): "+ → Instrument" opens its tab on Notes → Sample → Output, which // plays nothing until the Sample has a sound. With the blip, looped, every key of C4–B4 // sounds; a key held by the pointer plays until it is let go, the computer keyboard plays // as a tracker does, and a game's `Play note` releases its note after its duration. await showTab('scene'); await clickText('+'); await pickAssetType(t, 'Instrument'); const insCreated = await json(`{ tab: __gm.tab(), current: __gm.instrument.current(), info: __gm.instrument.info(), keys: __gm.instrument.keys(), assets: __gm.state.project.assets.filter((a) => a.type === 'instrument') .map((a) => ({ id: a.id, nodes: a.graph.nodes.map((n) => n.type) })), }`); check( 'the + menu creates an instrument, Notes → Sample → Output, and opens its tab; no key sounds yet', insCreated.tab === 'instrument' && insCreated.assets.length === 1 && insCreated.current === insCreated.assets[0].id && insCreated.assets[0].nodes.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput' && insCreated.info === 'No sound picked' && insCreated.keys.sounding.length === 0, JSON.stringify(insCreated), ); const insId = insCreated.assets[0]?.id; // Bottom left: the fitted graph is one row of three nodes, its links among them. const insCanvas = await json('__gm.instrument.canvasRect()'); await click(insCanvas.x + 30, insCanvas.y + insCanvas.h - 30, 'right'); const insPalette = await json('__gm.overlayTexts()'); check( 'its palette offers its own nodes and the effects, but no Input and no behavior node', ['Notes', 'Sample', 'Parameters', 'Sound FX', 'Envelope', 'Gain', 'Filter', 'Delay'].every((label) => insPalette.includes(label), ) && !insPalette.includes('Input') && !insPalette.includes('Branch'), JSON.stringify(insPalette.slice(0, 14)), ); await press('Escape'); await mutate(` const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}); Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, { sound: ${JSON.stringify(soundId)}, play: 'loop', });`); const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }'); check( 'with a sound picked the twelve keys from C4 to B4 sound, the others stay grey', insReady.info === '12 notes' && insReady.keys.sounding.join() === [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71].join(), JSON.stringify(insReady), ); await shot('34-instrument'); await evaluate('__gm.sound.stopAll()'); const beforeKey = await stats(); const e4 = await json('__gm.instrument.keyAt(64)'); await t.mouse('mouseMoved', e4.x, e4.y); await t.mouse('mousePressed', e4.x, e4.y); const heldKey = await waitFor(stats, (s) => s.started > beforeKey.started); // Longer than the blip: a looped note that ended with its sound would be gone by now. await sleep(600); const stillHeld = await json('{ stats: __gm.sound.stats(), keys: __gm.instrument.keys() }'); check( 'holding E4 plays the blip four semitones up, looped, as one layer of the instrument, and lights the key', heldKey.last?.file === `instrument:${insId}` && heldKey.last.pitches.join() === '4' && heldKey.last.layers === 1 && stillHeld.stats.playing === 1 && stillHeld.keys.held.join() === '64', JSON.stringify({ heldKey, stillHeld }), ); await t.mouse('mouseReleased', e4.x, e4.y); const letGo = await waitFor(stats, (s) => s.playing === 0); check( 'letting go of the key ends the note: its sample fades out and the voice goes', letGo.playing === 0 && (await json('__gm.instrument.keys()')).held.length === 0, JSON.stringify(letGo), ); // "Loop, then release": held, it loops 0.05 to 0.1 s; let go, it plays the 0.15 s after the loop. const setPlay = (fields) => mutate(` const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}); Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, ${JSON.stringify(fields)});`); await setPlay({ play: 'release', loopStart: 0.05, loopEnd: 0.1 }); const beforeRelease = await stats(); await t.mouse('mousePressed', e4.x, e4.y); await waitFor(stats, (s) => s.started > beforeRelease.started); await sleep(600); const heldLoop = await stats(); await t.mouse('mouseReleased', e4.x, e4.y); const leftAt = await audioNow(); const ringingKeys = await json('__gm.instrument.keys()'); const playedOut = await waitFor(audioNow, (s) => s.playing === 0); const afterKeys = await waitFor( () => json('__gm.instrument.keys()'), (keys) => keys.ringing.length === 0, ); // 0.15 s after the loop, up to two rounds more (the one it is in, the one the worklet read into) // and the 150 ms waitFor polls at: a fade would be gone in 0.01 s. const outFor = playedOut.clock - leftAt.clock; check( '"Loop, then release" loops while held, and let go plays on past the loop with its key half lit', heldLoop.playing === 1 && ringingKeys.held.length === 0 && ringingKeys.ringing.join() === '64' && outFor > 0.12 && outFor < 0.5 && afterKeys.ringing.length === 0, JSON.stringify({ heldLoop: heldLoop.playing, ringingKeys, outFor, afterKeys }), ); await setPlay({ play: 'loop', loopStart: 0, loopEnd: 0 }); // The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4. const beforeZ = await stats(); await press('z'); await sleep(200); const lowZ = await stats(); await press('ArrowRight'); await t.keyDown('z'); const typed = await waitFor(stats, (s) => s.started > lowZ.started); const typedKeys = await json('__gm.instrument.keys()'); await t.keyUp('z'); const typedOff = await waitFor(stats, (s) => s.playing === 0); check( 'the computer keyboard plays as a tracker: Z is C3 and silent here, one octave up it is C4 until let go', lowZ.started === beforeZ.started && typedKeys.octave === 4 && typedKeys.held.join() === '60' && typed.last?.pitches.join() === '0' && typedOff.playing === 0, JSON.stringify({ beforeZ: beforeZ.started, lowZ: lowZ.started, typed, typedKeys, typedOff }), ); await press('ArrowLeft'); // Twice the eleven keys off the root, all held at once: each plays through the pitch worklet, and // the ten oldest are cut, so no more than twelve phase vocoders run and the audio thread keeps up. await evaluate('__gm.sound.stopAll()'); const crowd = await json(`(() => { const voices = [...Array(22)].map((_, i) => __gm.instrument.play(61 + (i % 11))); return { started: voices.filter((v) => v > 0).length, playing: voices.map((v) => __gm.sound.playing(v)), stats: __gm.sound.stats() }; })()`); check( 'held at once, 22 notes off the root keep only the 12 newest on the pitch worklet', crowd.started === 22 && crowd.playing.join() === [...Array(22)].map((_, i) => i >= 10).join() && crowd.stats.vocoders === 12 && crowd.stats.playing === 12, JSON.stringify({ started: crowd.started, playing: crowd.playing, vocoders: crowd.stats.vocoders }), ); // Closing the tab ends what it plays: E4, looped and held by no key, falls silent with it. await evaluate('__gm.sound.stopAll()'); await json('__gm.instrument.play(64)'); const heldBeforeClose = await stats(); await evaluate(`__gm.closeDocument('asset:${insId}')`); await sleep(300); const afterClose = await stats(); await evaluate(`__gm.openDocument('asset:${insId}')`); check( 'closing the Instrument tab ends a note it still plays', heldBeforeClose.playing === 1 && afterClose.playing === 0, JSON.stringify({ held: heldBeforeClose.playing, closed: afterClose.playing }), ); // 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and // Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change // only plays with Audition on. Three undos bring the blip back. const insGraph = () => json(`(() => { const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null; return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') }; })()`); const clickHook = async (hook) => { const at = await json(`__gm.instrument.${hook}()`); await click(at.x, at.y); }; await evaluate('__gm.sound.stopAll()'); const beforePreset = await stats(); await clickHook('presetAt'); const presetMenu = await json('__gm.overlayTexts()'); await clickText('Laser'); await sleep(400); const preset = await stats(); const laser = await insGraph(); const presetInfo = await json('__gm.instrument.info()'); check( 'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays nothing', ['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) => presetMenu.includes(label), ) && laser.types.join() === 'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' && laser.chip?.followNote === false && laser.chip.slide < 0 && laser.envelope?.sustain === 0 && preset.started === beforePreset.started && presetInfo === '128 notes', JSON.stringify({ presetMenu, laser, started: [beforePreset.started, preset.started], presetInfo }), ); await shot('34-instrument-preset'); const beforeRandom = await stats(); await clickHook('randomizeAt'); const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started); const drawn = await insGraph(); await clickHook('mutateAt'); const mutated = await waitFor(stats, (s) => s.started > randomized.started); const moved = await insGraph(); check( 'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once', randomized.started > beforeRandom.started && drawn.chip?.wave === laser.chip.wave && drawn.chip.frequency !== laser.chip.frequency && mutated.started > randomized.started && JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]), JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }), ); const setFrequency = (hz) => mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}) .graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`); await sleep(300); const silent = await stats(); await setFrequency(300); await sleep(500); const unheard = await stats(); await clickHook('auditionAt'); const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled; await setFrequency(400); const auditioned = await waitFor(stats, (s) => s.started > unheard.started); await clickHook('auditionAt'); check( 'a change to the instrument is heard only with Audition on', unheard.started === silent.started && auditionOn && auditioned.started > unheard.started, JSON.stringify({ silent: silent.started, unheard: unheard.started, auditionOn, auditioned: auditioned.started, }), ); for (let i = 0; i < 3; i++) await t.clickToolbar('undo'); const restored = await insGraph(); check( 'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again', restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', JSON.stringify(restored.types), ); // 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and // plays nothing. One undo takes it back. await evaluate('__gm.sound.stopAll()'); const beforeLead = await stats(); await clickHook('presetAt'); await clickText('Square Lead'); await sleep(400); const lead = await stats(); const leadGraph = await insGraph(); const leadInfo = await json('__gm.instrument.info()'); await t.clickToolbar('undo'); const unled = await insGraph(); check( 'Preset → Square Lead plays every note at its own pitch, holds it and plays nothing; one undo takes it back', ['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) && leadGraph.chip?.followNote === true && leadGraph.envelope?.sustain > 0 && lead.started === beforeLead.started && leadInfo === '128 notes' && unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', JSON.stringify({ chip: leadGraph.chip, envelope: leadGraph.envelope, started: [beforeLead.started, lead.started], leadInfo, unled, }), ); await evaluate('__gm.sound.stopAll()'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify( node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }), )}); graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`); // A key still held in the Instrument tab is not part of the game: Play ends it. const preview = await json('__gm.instrument.play(64)'); const previewHeld = await json(`__gm.sound.playing(${preview})`); await showTab('scene'); const beforeNote = await stats(); await play(); const previewAfterPlay = await json(`__gm.sound.playing(${preview})`); check( 'Play ends what the tabs play: a note held in the Instrument tab is gone', preview > 0 && previewHeld && !previewAfterPlay, JSON.stringify({ preview, previewHeld, previewAfterPlay }), ); // The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard // well past the 0.25 s of the blip, never past its duration, and is gone soon after. const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })'); const noted = await waitFor(noteNow, (s) => s.stats.started > beforeNote.started); const heard = await trace(noteNow, (s) => s.stats.playing === 0); const noteAt = noted.stats.last?.at ?? 0; const heardAt = heard.filter((s) => s.stats.playing === 1).map((s) => +(s.clock - noteAt).toFixed(3)); const goneAt = +(heard.at(-1).clock - noteAt).toFixed(3); check( 'a game’s Play note plays G4 – the blip seven semitones up – and lets it go after its duration', noted.stats.last?.file === `instrument:${insId}` && noted.stats.last.pitches.join() === '7' && heardAt.some((t) => t > 0.5) && heardAt.every((t) => t < 1.6) && heard.at(-1).stats.playing === 0 && goneAt < 3, JSON.stringify({ last: noted.stats.last, heardAt, goneAt }), ); await stop(); // #332: the scheduler, driven by the hook – nothing in the editor plays a sequence yet. A bar of // quarters at 150 BPM in a loop: every note is planned before its second comes, each 0.4 s // after the one before, also across the seam into the next round. Where the music stands // is the audio clock less the output latency, counted from the first note. // #366: the audio clock moves on by a render block even between two reads in one expression, so // it is read before and after the position, and the position lies between the two. const bar = [60, 62, 64, 65].map((pitch, i) => ({ tick: i * 480, length: 240, note: pitch, velocity: 1, instrument: insId, })); await evaluate( `__gm.music.play(${JSON.stringify({ notes: bar })}, { tempo: 150, loop: { start: 0, end: 1920 } })`, ); const planned = await waitFor( () => json('__gm.music.planned()'), (p) => p.length >= 9, 40, ); const where = await json( `({ before: __gm.sound.clock(), position: __gm.music.position(), after: __gm.sound.clock(), latency: __gm.music.latency(), })`, ); await evaluate('__gm.music.stop()'); const musicOff = await waitFor(stats, (s) => s.playing === 0); const apart = planned.slice(1, 9).map((p, i) => +(p.at - planned[i].at).toFixed(6)); const ahead = planned.map((p) => +(p.at - p.planned).toFixed(3)); const ticksHeardAt = (clock) => ((clock - where.latency - planned[0].at) * 150 * 480) / 60; const heardTicks = [ticksHeardAt(where.before), ticksHeardAt(where.after)]; // How far the position lies past the first read, around the loop of 1920 ticks. const pastBefore = (((where.position.ticks - heardTicks[0]) % 1920) + 1920) % 1920; check( 'the scheduler plans a looped bar on the audio clock: every note ahead of its second, 0.4 s apart across the seam', planned.length >= 9 && planned .slice(0, 9) .map((p) => p.note) .join() === '60,62,64,65,60,62,64,65,60' && planned.every((p) => p.voice > 0 && Math.abs(p.duration - 0.2) < 1e-9 && p.tempo === 150) && apart.every((gap) => Math.abs(gap - 0.4) < 1e-6) && ahead.every((s) => s > 0 && s <= 0.16) && (pastBefore <= heardTicks[1] - heardTicks[0] + 1 || pastBefore > 1919) && where.position.bar === 1 && musicOff.playing === 0 && (await json('__gm.music.playing()')) === false, JSON.stringify({ apart, ahead, where, heardTicks, pastBefore, playing: musicOff.playing }), ); // A note planned ahead through the pitch worklet starts on its sample, also inside a render block: // rendered offline at pitch 0, which plays the sound as it is, so the first sample heard is it. const onsets = await evaluate(` (async () => { const RATE = 48000; const onset = async (startAt) => { const context = new OfflineAudioContext(1, RATE / 2, RATE); await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl()); const node = new AudioWorkletNode(context, 'pitch-player', { numberOfInputs: 0, outputChannelCount: [1], parameterData: { pitch: 0 }, processorOptions: { file: 'onset', channels: [new Float32Array(RATE).fill(0.5)], step: 1, loop: false, startAt }, }); node.connect(context.destination); const out = (await context.startRendering()).getChannelData(0); return out.findIndex((v) => Math.abs(v) > 1e-6); }; const at = [0.0123, 0.25, 0.05 + 0.4 * 2 / 3]; return { expected: at.map((s) => Math.round(s * RATE)), heard: await Promise.all(at.map(onset)) }; })()`); check( 'a pitched note planned ahead starts on its very sample, inside a render block too', onsets.heard.join() === onsets.expected.join() && onsets.expected.some((i) => i % 128 !== 0), JSON.stringify(onsets), ); // #335: an Envelope's curve as Chrome renders it – up in the attack, easing towards the sustain, // and from wherever it is when the note ends down to silence in the release, mid-attack too – // and a Gain it moves through a WaveShaper, from a quarter to full in equal ratios. const curves = await evaluate(` (async () => { const RATE = 48000; const envelope = { id: 'e', attack: 0.1, decay: 0.2, sustain: 0.5, release: 0.3 }; const render = async (releaseAt, moved) => { const context = new OfflineAudioContext(1, RATE, RATE); const driver = __gm.sound.envelopeDriver(context, envelope, 0); if (releaseAt !== null) __gm.sound.releaseEnvelope(driver.offset, envelope, 0, releaseAt); if (moved) { const steady = context.createConstantSource(); const stage = { kind: 'gain', gain: 1, mod: { envelope, from: 0.25, to: 1 } }; const made = __gm.sound.stageWith(context, stage, driver); steady.connect(made.input); made.output.connect(context.destination); steady.start(); } else driver.connect(context.destination); driver.start(); const out = (await context.startRendering()).getChannelData(0); return (s) => Math.round(out[Math.round(s * RATE)] * 10000) / 10000; }; const held = await render(0.5, false); const early = await render(0.05, false); const gain = await render(null, true); return { held: [0.05, 0.1, 0.3, 0.5, 0.65, 0.8].map(held), early: [0.05, 0.15, 0.35].map(early), gain: [0.05, 0.1, 0.9].map(gain), }; })()`); const closeTo = (values, expected) => values.every((v, i) => Math.abs(v - expected[i]) < 0.002); check( 'an Envelope rises, eases to its sustain and releases from where it is, and moves a Gain with that curve', closeTo(curves.held, [0.5, 1, 0.5005, 0.5, 0.0158, 0.0005]) && closeTo(curves.early, [0.5, 0.05, 0.0005]) && closeTo(curves.gain, [0.5, 1, 0.5]), JSON.stringify(curves), ); // A note of 0.2 s through an Envelope with a release of 1 s: the looped blip plays on through the // release, and the voice goes with it – without one it would end 10 ms after the note. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; const into = graph.edges.find((e) => e.to.node === graph.nodes.find((n) => n.type === 'InstrumentOutput').id); graph.nodes.push(${JSON.stringify(node('ins-env', 'InstrumentEnvelope', 300, 120, { release: 1 }))}); graph.edges.push({ id: 'ins-e-env', from: { node: 'ins-env', port: 'out' }, to: { ...into.to } }); into.to = { node: 'ins-env', port: 'in' };`); await evaluate('__gm.sound.stopAll()'); const beforeShaped = await stats(); await evaluate( `__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`, ); const shaped = await waitFor(noteNow, (s) => s.stats.started > beforeShaped.started); const ringOut = await trace(noteNow, (s) => s.stats.playing === 0); await evaluate('__gm.music.stop()'); const shapedAt = shaped.stats.last?.at ?? 0; const shapedGone = +(ringOut.at(-1).clock - shapedAt).toFixed(3); // 0.2 s of note and 1 s of release; up to the 150 ms waitFor polls at on top. check( 'a note through an Envelope plays on through its release and goes with it', shaped.stats.last?.envelopes === 1 && ringOut.some((s) => s.stats.playing === 1 && s.clock - shapedAt > 0.8) && ringOut.at(-1).stats.playing === 0 && shapedGone > 1.15 && shapedGone < 1.6, JSON.stringify({ last: shaped.stats.last, shapedGone }), ); // #329: the Sample swapped for a Chip, the Envelope's sustain at 0 and its decay 0.1 s. The note of // 0.2 s is over once the decay is, on its own – held to its end and through the release of // 1 s it would take 1.2 s. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes'); graph.nodes.push(${JSON.stringify(node('ins-chip', 'InstrumentChip', 120, 200, { wave: 'saw', slide: -12 }))}); graph.edges.find((e) => e.to.node === 'ins-env' && e.to.port === 'in').from = { node: 'ins-chip', port: 'out' }; graph.edges.push({ id: 'ins-e-chip', from: { node: notes.id, port: 'any' }, to: { node: 'ins-chip', port: 'trigger' } }); Object.assign(graph.nodes.find((n) => n.id === 'ins-env').params, { attack: 0.005, decay: 0.1, sustain: 0 });`); const beforeChip = await stats(); await evaluate( `__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`, ); const chipNote = await waitFor(noteNow, (s) => s.stats.started > beforeChip.started); const chipOut = await trace(noteNow, (s) => s.stats.playing === 0); await evaluate('__gm.music.stop()'); const chipGone = +(chipOut.at(-1).clock - (chipNote.stats.last?.at ?? 0)).toFixed(3); // 0.105 s of attack and decay; up to the 150 ms the polls take on top. check( 'a Chip plays a note without a sound file, and an Envelope with sustain 0 ends it after its decay', chipNote.stats.worklet === true && chipNote.stats.last?.file === `instrument:${insId}` && chipNote.stats.last?.envelopes === 1 && chipNote.stats.last?.duration === 0 && chipOut.at(-1).stats.playing === 0 && chipGone < 0.5, JSON.stringify({ last: chipNote.stats.last, chipGone }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-note'); graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-play', port: 'exec' }; const project = __gm.state.project; project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(insId)});`); // 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => { const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer()); const file = new File([bytes], 'drone.wav', { type: 'audio/wav' }); const data = new DataTransfer(); data.items.add(file); const canvas = document.querySelector('canvas'); canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true })); return file.size; })()`); await waitFor( () => assetsOfType('sound'), (list) => list.length === 2, ); const both = await assetsOfType('sound'); check( 'a dropped sound is uploaded and becomes an asset named after the file', dropped === DRONE_BYTES && both.length === 2 && both.some((a) => a.file === drone.file && a.name === 'drone'), JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }), ); // 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its // clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a // click picks one, and the picked clip stands between two handles. A clip is played by a // name of its own, its samples cut to the sample and ramped from silence at an edge inside // the file. One drag is one undo step. const droneId = both.find((a) => a.file === drone.file)?.id; await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); const tab = () => json('__gm.soundTab.span()'); const clips = () => json('__gm.soundTab.clips()'); const picked = () => json('__gm.soundTab.pickedClip()'); await waitFor(tab, (span) => span !== null); const tabOpened = { current: await json('__gm.soundTab.current()'), span: await tab(), info: await json('__gm.soundTab.info()'), clips: await clips(), picked: await picked(), reverse: await json('__gm.soundTab.reverseButton()'), played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`), }; check( 'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade', tabOpened.current === droneId && tabOpened.span?.start === 0 && Math.abs(tabOpened.span?.end - 0.75) < 0.001 && tabOpened.info === '0.75 s' && tabOpened.clips.length === 0 && tabOpened.picked === null && tabOpened.reverse.enabled === false && tabOpened.played === drone.file, JSON.stringify(tabOpened), ); // 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room // above a full-scale wave, in takes it past the top of the tab, the seconds stay where they // are, and Fit brings back the full height. const levelScale = await json('__gm.soundTab.scale()'); const levelRect = await json('__gm.soundTab.rect()'); const fullTop = await json('__gm.soundTab.fullScaleTop()'); const secondAt = (await json('__gm.soundTab.at(0.5)')).x; await t.wheel(levelScale.x, levelScale.y, 924); const zoomedOut = { level: await json('__gm.soundTab.level()'), top: await json('__gm.soundTab.fullScaleTop()'), secondAt: (await json('__gm.soundTab.at(0.5)')).x, }; await t.wheel(levelScale.x, levelScale.y, -1848); const zoomedIn = { level: await json('__gm.soundTab.level()'), top: await json('__gm.soundTab.fullScaleTop()'), }; const levelFit = await json('__gm.soundTab.fitButton()'); await click(levelFit.x, levelFit.y); await sleep(100); const refitted = { level: await json('__gm.soundTab.level()'), top: await json('__gm.soundTab.fullScaleTop()'), }; check( 'the wheel over the level scale zooms the height alone, and Fit puts it back', Math.abs(zoomedOut.level - 0.25) < 0.01 && Math.abs(zoomedOut.top - levelScale.y - (fullTop - levelScale.y) * 0.25) < 1 && zoomedOut.secondAt === secondAt && Math.abs(zoomedIn.level - 4) < 0.05 && zoomedIn.top < levelRect.y && refitted.level === 1 && Math.abs(refitted.top - fullTop) < 0.5, JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }), ); // 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360): // drawing both anew on every move took the frames down. const panFrom = await json('__gm.soundTab.at(0.375)'); await t.wheel(panFrom.x, panFrom.y, -924); const panInfo = async () => ({ ...(await json('__gm.soundTab.drawInfo()')), at: (await json('__gm.soundTab.at(0.375)')).x, }); const panBefore = await panInfo(); await t.mouse('mouseMoved', panFrom.x, panFrom.y); await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle'); for (let i = 1; i <= 8; i++) { await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 }); await sleep(20); } await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle'); await sleep(100); const panAfter = await panInfo(); const panMoved = panAfter.at - panBefore.at; await click(levelFit.x, levelFit.y); await sleep(100); check( 'a pan moves the wave drawn already, and the ruler labels keep their seconds', Math.abs(panMoved + 240) < 1 && panAfter.waveDraws === panBefore.waveDraws && Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 && panAfter.rulerTexts - panBefore.rulerTexts <= 2, JSON.stringify({ panBefore, panAfter, panMoved }), ); const clipProps = async () => (await clips())[0]?.props; let dragFrom = await json('__gm.soundTab.stripAt(0.1)'); let dragTo = await json('__gm.soundTab.stripAt(0.3)'); await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y); const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') }; const clipId = spanned.clips[0]?.id; check( 'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file', spanned.clips.length === 1 && spanned.clips[0].name === 'Clip 1' && Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 && Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 && spanned.picked === clipId && spanned.info === `${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` && (await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file, JSON.stringify(spanned), ); await press('z', { ctrl: true }); const undoneClip = { clips: await clips(), picked: await picked() }; await press('z', { ctrl: true, shift: true }); const redoneClip = await clips(); const beside = await json('__gm.soundTab.stripAt(0.5)'); await click(beside.x, beside.y); const pickedBeside = await picked(); const faintBeside = await json('__gm.soundTab.faintClips()'); await shot('34-sound-faint-clip'); const onBand = await json('__gm.soundTab.stripAt(0.2)'); await click(onBand.x, onBand.y); const faintPicked = await json('__gm.soundTab.faintClips()'); check( 'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound, the clip then faint over the wave', undoneClip.clips.length === 0 && undoneClip.picked === null && redoneClip.map((c) => c.id).join() === clipId && pickedBeside === null && faintBeside.join() === 'Clip 1' && faintPicked.length === 0 && (await picked()) === clipId, JSON.stringify({ undoneClip, redoneClip, pickedBeside, faintBeside, faintPicked, picked: await picked(), }), ); const inClip = await json('__gm.soundTab.at(0.2)'); await click(inClip.x, inClip.y); const pickedInClip = await picked(); const besideClip = await json('__gm.soundTab.at(0.6)'); await click(besideClip.x, besideClip.y); const pickedBesideClip = await picked(); await click(onBand.x, onBand.y); check( 'a click over the wave inside the picked clip keeps it, beside it picks the sound', pickedInClip === clipId && pickedBesideClip === null && (await picked()) === clipId, JSON.stringify({ pickedInClip, pickedBesideClip, picked: await picked() }), ); dragFrom = await json('__gm.soundTab.at(0.3)'); dragTo = await json('__gm.soundTab.at(0.5)'); await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y); await t.mouse('mouseMoved', dragFrom.x, dragFrom.y); const onHandle = await json('__gm.soundTab.cursor()'); await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y); const lengthened = await clipProps(); await press('z', { ctrl: true }); check( 'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged', onHandle === 'ew-resize' && Math.abs(lengthened?.start - 0.1) < 0.01 && Math.abs(lengthened?.end - 0.5) < 0.01 && Math.abs((await clipProps())?.end - 0.3) < 0.01, JSON.stringify({ onHandle, lengthened, undone: await clipProps() }), ); const clipCut = await clipProps(); const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`); const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1; check( 'the clip is played by a name of its own, cut to the sample', cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` && cutSamples?.length === keptSamples, JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }), ); check( 'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file', cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01, JSON.stringify(cutSamples), ); // 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345). const tabFadeOut = await json('__gm.soundTab.fadeOutField()'); await click(tabFadeOut.x, tabFadeOut.y); await t.typeText('40'); await press('Enter'); await sleep(150); const curveOutButton = await json('__gm.soundTab.curveOutButton()'); await click(curveOutButton.x, curveOutButton.y); await clickText('Exponential'); await sleep(150); const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`); check( 'the header sets the fade out and its curve on their own, the fade in staying Linear', (await clipProps())?.fadeOut === 40 && (await clipProps())?.fadeOutCurve === 'exponential' && ((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' && (await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' && (await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' && curved.endsWith('~0.005~0.04~linear~exponential'), JSON.stringify({ props: await clipProps(), curved }), ); await press('z', { ctrl: true }); await press('z', { ctrl: true }); const tabFadeIn = await json('__gm.soundTab.fadeInField()'); await click(tabFadeIn.x, tabFadeIn.y); await t.typeText('0'); await press('Enter'); const fadeOutField = await json('__gm.soundTab.fadeOutField()'); await click(fadeOutField.x, fadeOutField.y); await t.typeText('0'); await press('Enter'); await sleep(150); const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); check( 'after two undos the curve is Linear again, and both fades go to 0 in the header', (await clipProps())?.fadeIn === 0 && (await clipProps())?.fadeOut === 0 && ((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' && unfaded.endsWith('~0~0~linear~linear'), JSON.stringify({ props: await clipProps(), unfaded }), ); // The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the // exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp. const rawSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(unfaded)})`); const outRamp = Math.round(0.04 * (rawSamples?.rate ?? 0)); const fall = [1, 2, 3] .map((i) => ({ i, raw: rawSamples?.tail.at(-1 - i), curved: curvedSamples?.tail.at(-1 - i) })) .filter((s) => Math.abs(s.raw) > 1e-3); check( 'the end falls along the curve: (i / ramp)² of the drone on an exponential one', curvedSamples?.tail.at(-1) === 0 && fall.length > 0 && fall.every((s) => Math.abs(s.curved / s.raw / (s.i / outRamp) ** 2 - 1) < 1e-3), JSON.stringify({ fall, outRamp }), ); await evaluate('__gm.sound.stopAll()'); const tabPlay = await json('__gm.soundTab.playButton()'); await click(tabPlay.x, tabPlay.y); const tabHeard = await waitFor(stats, (s) => s.last?.file === unfaded); const tabPlayhead = await waitFor( () => json('__gm.soundTab.playhead()'), (seconds) => seconds !== null && seconds > 0.1, 10, ); const tabEnded = await waitFor( () => json('__gm.soundTab.playing()'), (on) => on === false, ); check( 'Play plays only the picked clip, its playhead running inside it, and stops when it ends', tabHeard.last?.file === unfaded && Math.abs(tabHeard.last?.duration - 0.2) < 0.01 && tabPlayhead !== null && tabPlayhead >= 0.1 && tabPlayhead <= 0.3 && tabEnded === false && (await json('__gm.soundTab.playhead()')) === null, JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }), ); // 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321). const reverseButton = await json('__gm.soundTab.reverseButton()'); await click(reverseButton.x, reverseButton.y); await sleep(150); const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`); check( 'Reverse turns the clip around: its first samples are the last of the clip played forwards', (await clipProps())?.reverse === true && (await json('__gm.soundTab.reverseButton()')).on === true && reversed === `${unfaded}~reverse` && reversedSamples?.length === rawSamples?.length && reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)), JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }), ); // Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged // past both ends of it. for (const [from, to] of [ [0.1, -0.1], [0.3, 0.9], ]) { const a = await json(`__gm.soundTab.at(${from})`); const b = await json(`__gm.soundTab.at(${to})`); await t.drag(a.x, a.y, b.x, b.y); } const whole = await clipProps(); await click(tabPlay.x, tabPlay.y); await waitFor( () => json('__gm.soundTab.playhead()'), (seconds) => seconds !== null, 10, ); const backwards = ( await trace( () => json('__gm.soundTab.playhead()'), (seconds) => seconds === null, ) ).filter((seconds) => seconds !== null); check( 'played backwards, the playhead runs from the end of the clip towards its start', whole?.reverse === true && whole?.start === 0 && whole?.end === 0 && backwards.length >= 2 && backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) && backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) && backwards.at(-1) < backwards[0], JSON.stringify({ whole, backwards }), ); await shot('34-sound-tab'); // 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1". await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' }); // The row is named after the port, as the node body names it. const soundRow = await json("__gm.inspectorField('sound', true)"); await click(soundRow.x, soundRow.y); const soundOffered = await json('__gm.overlayTexts()'); await clickText('drone / Clip 1'); await sleep(150); const playNode = async () => (await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find( (n) => n.id === 'sn-play', ); check( 'a sound field offers each clip after its sound, and picking one names the clip', soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 && (await playNode())?.params.sound === clipId, JSON.stringify({ soundOffered, node: await playNode() }), ); // 34h-v. Delete takes the picked clip away, and the sound stays. await evaluate( `__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`, ); await waitFor(picked, (id) => id === clipId); await press('Delete'); check( 'Delete takes the picked clip away, the sound staying on screen', (await clips()).length === 0 && (await picked()) === null && (await json('__gm.soundTab.current()')) === droneId, JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }), ); // 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes // one; the tab has a node for it and for the sound, each linked to the master. Links drawn // there and one deleted send the sound into a bus and that bus into the other; the Sound // tab still plays it on the master (#368). A click on a node shows its asset, on the // master the master's volume. for (let i = 0; i < 2; i++) { await clickText('+'); await pickAssetType(t, 'Bus'); await sleep(150); } const [busA, busB] = await assetsOfType('bus'); await t.clickToolbar('menu'); await clickText('Master routing'); await sleep(300); const routing = () => json('__gm.routing.graph()'); const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`); const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() }; const labels = await json( `[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`, ); await select({ kind: 'asset', assetId: droneId }); const busRow = await json("__gm.inspectorField('Bus', true)"); await select({ kind: 'asset', assetId: busA.id }); const intoRow = await json("__gm.inspectorField('Into', true)"); check( 'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows', opened.shown && [droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) && [droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) && JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) && [busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) && busRow === null && intoRow === null, JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }), ); const portOf = (nodeId, key, isOutput) => json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); const link = async (from, to) => { const a = await portOf(from, 'out', true); const b = await portOf(to, 'in', false); await drag(a.x, a.y, b.x, b.y); await sleep(200); }; // The link is picked by the model: its line may run behind a node, as lines do. const unlink = async (from, to) => { const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to); await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] }); await press('Delete'); await sleep(150); }; await link(droneId, busB.id); await link(busB.id, busA.id); await unlink(droneId, '#master'); await unlink(busB.id, '#master'); await shot('h3-routing'); await mutate(` __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`); await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); // The preview of a tab leaves the routing out (#368): heard on the master, the buses stay silent. const tapped = JSON.stringify(['mix', `mix:${busB.id}`]); await json(`__gm.routing.peaks(${tapped})`); const startedBefore = (await stats()).started; await click(tabPlay.x, tabPlay.y); await waitFor(stats, (s) => s.started > startedBefore, 10); await sleep(300); const buses = await json('__gm.sound.buses()'); const heardOn = (await stats()).last?.bus; const peak = await waitFor( () => json(`__gm.routing.peaks(${tapped})`), (peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01, ); await click(tabPlay.x, tabPlay.y); const levelOf = (name) => buses.find((bus) => bus.name === name); check( 'linked in the routing, the mix sends the sound’s bus into the other; the Sound tab plays it on the master, past both', links(await routing()) .filter((l) => l.startsWith(droneId) || l.startsWith(busB.id)) .join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` && heardOn === 'mix' && levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` && levelOf(`mix:${busA.id}`)?.into === 'mix' && levelOf(`mix:${busA.id}`)?.gain === 0.5 && levelOf('mix')?.into === null && Math.max(...(peak?.mix ?? [0])) > 0.01 && Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) < 0.001, JSON.stringify({ links: links(await routing()), heardOn, buses, peak }), ); await t.clickToolbar('menu'); await clickText('Master routing'); await sleep(200); let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`); await click(at.x, at.y); await sleep(200); const busShown = await json("__gm.inspectorField('Volume', true)"); at = await json("__gm.routing.nodeAt('#master')"); await click(at.x, at.y); await sleep(200); await shot('h3-master'); const masterField = await json("__gm.inspectorField('Volume', true)"); await click(masterField.x, masterField.y); await press('a', { ctrl: true }); await t.typeText('0.6'); await press('Enter'); await sleep(150); const masterSet = await json('__gm.state.project.masterVolume'); await t.clickToolbar('undo'); await sleep(150); const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') }; at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`); await click(at.x, at.y); await click(at.x, at.y); await sleep(300); check( 'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound', busShown !== null && masterShown.set === 0.6 && masterShown.undone === 1 && (await json('__gm.soundTab.current()')) === droneId && (await json('__gm.routing.shown()')) === false, JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }), ); await t.clickToolbar('menu'); await clickText('Project settings…'); const masterAt = await json('__gm.projectSettingsMasterAt()'); await click(masterAt.volume.x, masterAt.volume.y); await press('a', { ctrl: true }); await t.typeText('0.6'); await press('Enter'); await clickText('Apply'); await sleep(200); const mastered = { project: await json('__gm.state.project.masterVolume'), heard: await json("__gm.sound.mixVolume('mix')"), }; await t.clickToolbar('undo'); await sleep(200); const undone = { project: await json('__gm.state.project.masterVolume'), heard: await json("__gm.sound.mixVolume('mix')"), }; check( 'the master volume is a project setting the mix follows, and undo gives the old one back', mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1, JSON.stringify({ masterAt, mastered, undone }), ); // The routing starts again from the assets with the next edit: everything on the master. await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.type !== 'bus'); p.routing = { nodes: [], edges: [] };`); // 34i. The step takes back everything it made, so the steps after it see the fixture again. await mutate(` const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]); const project = __gm.state.project; project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( (o) => !['sn-box', 'sn-brain'].includes(o.id)); __gm.state.select(null); __gm.lookAt(0, 0);`); check( 'the step leaves nothing of its own behind', (await assetsOfType('sound')).length === 0 && (await stats()).playing === 0, JSON.stringify(await assetsOfType('sound')), ); }