import { execSync } from 'node:child_process'; import { sineWav } from '../lib/audio.mjs'; import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { uploadSound } from '../lib/projects.mjs'; import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs'; import { sampleZoom } from './lib/zoomTransition.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', 'Parametric 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 the voice plays as if it were not there. Since // #393 the Delay is a device, which writes no setting rows, so "Bypass: true" is not read. 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, and the voice passes only the gain', bypassed.alpha < 0.5 && bypassed.gain === 1 && 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 }), ); // #376: the wheel on free space zooms the curve to ±12 dB, so the band's handle moves out with its // gain. The zoom is saved on the node, the Q stays, and Ctrl+Z undoes the Q, not the zoom. const eqFree = await json('__gm.eq.pointAt(50, 12)'); await t.wheel(eqFree.x, eqFree.y, -100); const eqZoomed = await json(`({ range: __gm.eq.range(), saved: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes .find((n) => n.id === 'fx-eq').params.range, q: __gm.eq.bands()[0].q, handle: __gm.eq.handleAt(0), at: __gm.eq.pointAt(4000, -6), })`); await press('z', { ctrl: true }); const eqZoomUndone = await json('({ range: __gm.eq.range(), q: __gm.eq.bands()[0].q })'); await press('z', { ctrl: true, shift: true }); check( 'the wheel on free space zooms the curve to ±12 dB, saved on the node, and Ctrl+Z leaves the zoom', eqZoomed.range === 12 && eqZoomed.saved === 12 && eqZoomed.q === eqWheeled.q && eqZoomed.handle.y > eqHandleBack.y + 10 && Math.abs(eqZoomed.handle.y - eqZoomed.at.y) < 1 && eqZoomUndone.range === 12 && eqZoomUndone.q < eqWheeled.q, JSON.stringify({ eqHandleBack, eqZoomed, eqZoomUndone }), ); // #377: at 1000 ms a frame after the wheel the dB are on their way from ±12 to ±6, and the node // holds the step at once. const eqZoom = await sampleZoom( t, eqFree, `({ scale: __gm.eq.zoomShown().range, moving: __gm.eq.zoomShown().moving, saved: __gm.eq.range() })`, ); check( 'a zoom of the curve is shown on its way and lands where the jump does', eqZoom.ok && eqZoom.samples.start.scale === 12 && eqZoom.samples.midway.saved === 6, eqZoom.detail, ); await shot('34-parametric-eq'); // #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node: // the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not // seen, no tap feeds an analyser any more, though the voice still plays. await evaluate(`__gm.floatDocument(${JSON.stringify(`eq:${fxId}:fx-eq`)})`); await evaluate('__gm.soundfx.setParam("depth", 0)'); await evaluate('__gm.sound.stopAll()'); const framesBefore = (await json('__gm.eq.spectrum()')).frames; await evaluate('__gm.soundfx.play()'); const spectrum = await waitFor( () => json('__gm.eq.spectrum()'), (s) => s.frames >= framesBefore + 3, ); await shot('34-eq-spectrum'); const nearBlip = (hz) => Math.abs(hz - 880) <= spectrum.binHz; check( 'floated beside the Sound FX tab, the EQ draws the blip’s 880 Hz as the loudest bin on both sides', spectrum.taps.listened === `${fxId}:fx-eq` && spectrum.frames >= framesBefore + 3 && nearBlip(spectrum.peakIn) && nearBlip(spectrum.peakOut) && spectrum.levelOut > -60, JSON.stringify(spectrum), ); const eqWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'eq'); await click(eqWindow.dock.x, eqWindow.dock.y); const unseen = await evaluate(`(async () => { const frame = () => new Promise((done) => requestAnimationFrame(() => done())); __gm.sound.stopAll(); const before = __gm.sound.stats().started; __gm.soundfx.play(); for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame(); await frame(); const seen = __gm.eq.spectrum().taps; // Not the sound FX graph: the node there listens to the same tap (#381). __gm.showTab('scene'); await frame(); await frame(); return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing }; })()`); check( 'once the EQ’s tab is not seen, no tap feeds an analyser, though the voice still plays', unseen.seen.held >= 2 && unseen.seen.feeding === unseen.seen.held && unseen.unseen.listened === '' && unseen.unseen.held >= 2 && unseen.unseen.feeding === 0 && unseen.playing === 1, JSON.stringify(unseen), ); await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); // #381: the EQ node shows the panel of its tab at full size, its inputs on the left and its output // on the right. A double-click on its curve adds a band without opening the tab, a drag is one // undo step, the wheel over a band zooms the graph and leaves its Q and the dB alone (#383), // and a right-click takes the band. Played, the node // draws the spectrum, its tab open in the row. await evaluate('__gm.soundfx.fit()'); await sleep(100); const eqPanel = () => json("__gm.eq.panel('fx-eq')"); const eqFace = await eqPanel(); const eqRect = eqFace.rect; const besideEq = (p) => p.y > eqRect.y && p.y < eqRect.y + eqRect.h; check( 'a Parametric EQ node shows the panel of its tab at full size, its inputs on the left of it and its output on the right', eqFace.shown && eqFace.size.w === 720 && eqFace.size.h === 360 && Object.values(eqFace.inputs).length >= 1 && Object.values(eqFace.inputs).every((p) => p.x < eqRect.x && besideEq(p)) && Object.values(eqFace.outputs).length === 1 && Object.values(eqFace.outputs).every((p) => p.x > eqRect.x + eqRect.w && besideEq(p)) && eqFace.curve.h > eqRect.h / 2 && eqFace.bands.length === 1, JSON.stringify(eqFace), ); const eqNodeAt = (hz, db) => json(`__gm.eq.panelPointAt('fx-eq', ${hz}, ${db})`); const eqAddAt = await eqNodeAt(250, 6); await t.doubleClick(eqAddAt.x, eqAddAt.y); const eqNodeAdded = { panel: await eqPanel(), docs: await json('__gm.documents()') }; const eqNodeTo = await eqNodeAt(500, -3); const eqNodeHandle = eqNodeAdded.panel.handles[1]; await drag(eqNodeHandle.x, eqNodeHandle.y, eqNodeTo.x, eqNodeTo.y); const eqNodeDragged = (await eqPanel()).bands[1]; await press('z', { ctrl: true }); const eqNodeUndone = (await eqPanel()).bands[1]; const eqRangeBefore = await json('__gm.eq.range()'); await t.wheel(eqNodeHandle.x, eqNodeHandle.y, -100); const eqNodeWheeled = { panel: await eqPanel(), range: await json('__gm.eq.range()') }; await evaluate('__gm.soundfx.fit()'); await sleep(100); const eqNodeLast = (await eqPanel()).handles[1]; await click(eqNodeLast.x, eqNodeLast.y, 'right'); const eqNodeRemoved = await eqPanel(); check( 'in the node, a double-click on the curve adds a band without opening the tab, a drag moves it as one undo step, the wheel over it zooms the graph and leaves its Q, and a right-click takes it', eqNodeAdded.panel.bands.length === 2 && bandNear(eqNodeAdded.panel.bands[1], 250, 6) && eqNodeAdded.panel.picked === 1 && eqNodeAdded.docs.active === `asset:${fxId}` && bandNear(eqNodeDragged, 500, -3) && bandNear(eqNodeUndone, 250, 6) && eqNodeWheeled.panel.bands[1].q === eqNodeUndone.q && eqNodeWheeled.panel.rect.w > eqRect.w + 10 && eqNodeWheeled.range === eqRangeBefore && eqNodeRemoved.bands.length === 1 && bandNear(eqNodeRemoved.bands[0], 4000, -6), JSON.stringify({ eqNodeAdded, eqNodeDragged, eqNodeUndone, eqNodeWheeled, eqNodeRemoved }), ); await evaluate('__gm.soundfx.fit()'); await evaluate('__gm.sound.stopAll()'); const eqNodeFrames = (await eqPanel()).spectrum.frames; await evaluate('__gm.soundfx.play()'); const eqNodeHeard = await waitFor(eqPanel, (p) => p.spectrum.frames >= eqNodeFrames + 3); await evaluate('__gm.sound.stopAll()'); check( 'played, the EQ node draws the blip’s 880 Hz behind its curve, its tab open in the row', eqNodeHeard.spectrum.frames >= eqNodeFrames + 3 && nearBlip(eqNodeHeard.spectrum.peakOut) && (await json('__gm.documents()')).docs.includes(`eq:${fxId}:fx-eq`), JSON.stringify(eqNodeHeard.spectrum), ); await shot('34-eq-node'); // #385: the EQ node is resized by its lower right corner, one undo step: small, the row under its // curve goes, and a little larger it comes back with what fits of it, from the left. const eqCorner = (await eqPanel()).corner; await drag(eqCorner.x, eqCorner.y, eqCorner.x - 600, eqCorner.y - 400); const eqLeast = { panel: await eqPanel(), size: await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-eq').size`, ), }; await press('z', { ctrl: true }); const eqFull = await eqPanel(); const zoom = eqFull.rect.w / eqFull.size.w; await drag(eqFull.corner.x, eqFull.corner.y, eqFull.corner.x - 300 * zoom, eqFull.corner.y - 160 * zoom); const eqMid = await eqPanel(); const eqRow = ['Band', 'Type', 'Hz', 'dB', 'Q', 'Slope', 'End']; const leftPart = (row) => row.every((name, i) => name === eqRow[i]); check( 'an EQ node is resized by its corner as one undo step: small, its row goes; larger, the groups that fit come back from the left', eqLeast.size?.w === 240 && eqLeast.size.h === 120 && eqLeast.panel.size.w === 240 && eqLeast.panel.rows.row.length === 0 && eqFull.size.w === 720 && eqFull.rows.row.join() === eqRow.join() && eqFull.rows.help === false && eqMid.size.w < 720 && eqMid.rows.row.length >= 1 && eqMid.rows.row.length < eqRow.length && leftPart(eqMid.rows.row), JSON.stringify({ eqLeast, eqFull: { size: eqFull.size, rows: eqFull.rows }, eqMid: { size: eqMid.size, rows: eqMid.rows }, }), ); await press('z', { ctrl: true }); // #385: a Filter node is edited in its node as one band of the curve: its handle is dragged for the // frequency and the gain as one undo step. A double-click on its title opens the same in a tab, // Delete there leaves the node, and a small window draws what fits of the row and no help. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-filter', 'SoundFxFilter', -240, 600, { filter: 'peaking', cutoff: 600, gain: -8, q: 2 }))}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e11'); graph.edges.push( ${JSON.stringify(edge('fx-e12', 'fx-eq', 'out', 'fx-filter', 'in'))}, ${JSON.stringify(edge('fx-e13', 'fx-filter', 'out', 'fx-out', 'in'))}, );`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const filterPanel = () => json("__gm.eq.panel('fx-filter')"); const filterFace = await filterPanel(); const filterRect = filterFace.rect; const filterTo = await json("__gm.eq.panelPointAt('fx-filter', 2000, 6)"); await drag(filterFace.handles[0].x, filterFace.handles[0].y, filterTo.x, filterTo.y); const filterDragged = await filterPanel(); await press('z', { ctrl: true }); const filterUndone = await filterPanel(); check( 'a Filter node shows its curve with one band between its ports, dragged for frequency and gain as one undo step', filterFace.shown && filterFace.size.w === 560 && filterFace.size.h === 280 && Object.values(filterFace.inputs).every((p) => p.x < filterRect.x) && Object.values(filterFace.outputs).every((p) => p.x > filterRect.x + filterRect.w) && filterFace.rows.row.join() === 'Type,Hz,dB,Q,Slope' && filterFace.fields.type === 'Peaking' && bandNear(filterFace.bands[0], 600, -8) && bandNear(filterDragged.bands[0], 2000, 6) && bandNear(filterUndone.bands[0], 600, -8) && filterDragged.bands[0].q === 2, JSON.stringify({ filterFace, filterDragged: filterDragged.bands, filterUndone: filterUndone.bands }), ); const filterTitle = await json("__gm.soundfx.nodeAt('fx-filter')"); await t.doubleClick(filterTitle.x, filterTitle.y); const filterTab = { shown: await json('__gm.filter.shown()'), rows: await json('__gm.filter.rows()'), docs: await json('__gm.documents()'), }; await press('Delete'); const filterKept = await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.some((n) => n.id === 'fx-filter')`, ); await evaluate(`__gm.floatDocument(${JSON.stringify(`filter:${fxId}:fx-filter`)})`); const filterWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter'); await drag(filterWindow.grip.x, filterWindow.grip.y, filterWindow.box.x + 470, filterWindow.box.y + 250); const filterSmall = { rows: await json('__gm.filter.rows()'), labels: await json('__gm.filter.labels()') }; await shot('34-filter-window'); const filterRow = ['Type', 'Hz', 'dB', 'Q', 'Slope']; check( 'a double-click opens the Filter in a tab with its help, Delete there leaves the node, and a small window draws what fits of the row and no help', filterTab.shown?.nodeId === 'fx-filter' && filterTab.docs.active === `filter:${fxId}:fx-filter` && filterTab.rows.row.join() === filterRow.join() && filterTab.rows.help === true && filterKept && filterSmall.rows.help === false && filterSmall.rows.row.length >= 1 && filterSmall.rows.row.length < filterRow.length && filterSmall.rows.row.every((name, i) => name === filterRow[i]), JSON.stringify({ filterTab, filterKept, filterSmall }), ); // #385: played, the Filter node and its window both draw what passes it, as the EQ does. await evaluate('__gm.sound.stopAll()'); const filterSpectra = async () => ({ node: (await filterPanel()).spectrum, tab: await json('__gm.filter.spectrum()'), }); const filterFrames = await filterSpectra(); await evaluate('__gm.soundfx.play()'); const filterHeard = await waitFor( filterSpectra, (s) => s.node.frames >= filterFrames.node.frames + 3 && s.tab.frames >= filterFrames.tab.frames + 3, ); await evaluate('__gm.sound.stopAll()'); check( 'played, the Filter node and its window draw the blip’s 880 Hz behind the curve', filterHeard.node.frames >= filterFrames.node.frames + 3 && filterHeard.tab.frames >= filterFrames.tab.frames + 3 && nearBlip(filterHeard.node.peakIn) && nearBlip(filterHeard.tab.peakIn) && filterHeard.tab.taps.listened.split(' ').includes(`${fxId}:fx-filter`), JSON.stringify({ filterFrames, filterHeard }), ); const filterClose = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter'); await click(filterClose.close.x, filterClose.close.y); await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); 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-filter'); graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))});`); 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 }), ); // #378: a Compressor behind the gain opens a tab of its own as well. Its knobs turn with a drag up // (one undo step), a notch of the wheel and a double-click back to the default, and take a // typed number. The end of its curve is dragged for the ratio. Played, the level going out // stays under the one coming in, and a knob whose input is linked is greyed. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify( node('fx-comp', 'SoundFxCompressor', -240, 0, { threshold: -30, ratio: 4, knee: 0, }), )}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e12', 'fx-gain', 'out', 'fx-comp', 'in'))}, ${JSON.stringify(edge('fx-e13', 'fx-comp', 'out', 'fx-out', 'in'))}, );`); const compNode = await json("__gm.soundfx.nodeAt('fx-comp')"); await t.doubleClick(compNode.x, compNode.y); const compShown = await json( '({ shown: __gm.compressor.shown(), docs: __gm.documents(), knobs: __gm.compressor.knobs() })', ); check( 'a double-click opens the Compressor in a tab, a knob for each of its numbers', compShown.shown?.nodeId === 'fx-comp' && compShown.docs.active === `compressor:${fxId}:fx-comp` && compShown.knobs.threshold.value === -30 && compShown.knobs.ratio.value === 4 && compShown.knobs.knee.value === 0 && compShown.knobs.attack.value === 0.003 && Object.keys(compShown.knobs).length === 6, JSON.stringify(compShown), ); const thresholdDial = await json("__gm.compressor.dialAt('threshold')"); await drag(thresholdDial.x, thresholdDial.y, thresholdDial.x, thresholdDial.y - 20); const compDragged = await json('__gm.compressor.numbers()'); await press('z', { ctrl: true }); const compUndone = await json('__gm.compressor.numbers()'); await press('z', { ctrl: true, shift: true }); const ratioDial = await json("__gm.compressor.dialAt('ratio')"); await t.wheel(ratioDial.x, ratioDial.y, -100); const kneeDial = await json("__gm.compressor.dialAt('knee')"); await t.doubleClick(kneeDial.x, kneeDial.y); const makeupField = await json("__gm.compressor.fieldAt('makeup')"); await click(makeupField.x, makeupField.y); await t.typeText('3'); await press('Enter'); await sleep(150); const compTurned = await json('({ numbers: __gm.compressor.numbers(), knobs: __gm.compressor.knobs() })'); check( 'a knob turns up with a drag up as one undo step, a notch of the wheel, a double-click and a typed number', compDragged.threshold === -20 && compUndone.threshold === -30 && compTurned.numbers.threshold === -20 && compTurned.numbers.ratio > 4 && compTurned.numbers.ratio < 5 && compTurned.numbers.knee === 6 && compTurned.numbers.makeup === 3 && compTurned.knobs.makeup.value === 3, JSON.stringify({ compDragged, compUndone, compTurned }), ); // Auto-gain is a switch (#378): a click turns it on as one undo step, and it says what it gives. const autoOff = await json('__gm.compressor.autoGain()'); await click(autoOff.at.x, autoOff.at.y); await sleep(100); const autoOn = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })'); await press('z', { ctrl: true }); await sleep(100); const autoUndone = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })'); check( 'the auto-gain switch turns on with a click as one undo step and says the dB it gives', !autoOff.on && autoOff.says === 'off' && autoOn.on && autoOn.numbers.autoGain === true && /^\+\d+\.\d dB$/.test(autoOn.says) && !autoUndone.on && autoUndone.numbers.autoGain === false && autoUndone.says === 'off', JSON.stringify({ autoOff, autoOn, autoUndone }), ); const ratioHandle = await json("__gm.compressor.handleAt('ratio')"); const ratioTo = await json('__gm.compressor.pointAt(0, -10)'); await drag(ratioHandle.x, ratioHandle.y, ratioTo.x, ratioTo.y); const compCurve = await json( "({ numbers: __gm.compressor.numbers(), handle: __gm.compressor.handleAt('ratio'), at: __gm.compressor.pointAt(0, -10) })", ); await press('z', { ctrl: true }); const compCurveUndone = await json('__gm.compressor.numbers()'); check( 'dragging the end of the curve sets the ratio that ends there, as one undo step', compCurve.numbers.ratio < compTurned.numbers.ratio && Math.abs(compCurve.handle.y - compCurve.at.y) < 2 && compCurveUndone.ratio === compTurned.numbers.ratio, JSON.stringify({ compCurve, compCurveUndone }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; Object.assign(graph.nodes.find((n) => n.id === 'fx-comp').params, { threshold: -50, ratio: 8, knee: 0, makeup: 0 });`); await evaluate('__gm.sound.stopAll()'); const levelsBefore = (await json('__gm.compressor.levels()')).frames; await evaluate('__gm.soundfx.play()'); const compLevels = await waitFor( () => json('__gm.compressor.levels()'), (l) => l.frames >= levelsBefore + 3 && l.reduction > 3, ); await shot('34-compressor'); check( 'played, the tab reads the level on both sides of the node, the one going out squeezed under the one coming in', compLevels.taps.listened === `${fxId}:fx-comp` && compLevels.in > -50 && compLevels.out < compLevels.in && compLevels.reduction > 3, JSON.stringify(compLevels), ); await evaluate('__gm.sound.stopAll()'); // Floated and sized down as far as it goes, the window keeps every knob; sized up, the knobs stay on // the bottom line and the curve in the middle of the height to its right. await evaluate(`__gm.floatDocument(${JSON.stringify(`compressor:${fxId}:fx-comp`)})`); const compWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor'); const compLayout = async () => { const win = await compWindow(); const at = await json( "({ first: __gm.compressor.dialAt('threshold'), last: __gm.compressor.autoGain().at, ...__gm.compressor.pictures() })", ); const inside = (p) => p.x > win.box.x && p.x < win.box.x + win.box.w && p.y < win.box.y + win.box.h - 20; return { box: win.box, ...at, inside: inside(at.first) && inside(at.last) }; }; let floatedComp = await compWindow(); await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x - 2000, floatedComp.grip.y - 2000); const compSmall = await compLayout(); floatedComp = await compWindow(); await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x + 2000, floatedComp.grip.y + 2000); const compLarge = await compLayout(); const { levels: bigLevels, curve: bigCurve } = compLarge; check( 'a floated Compressor holds every knob at its least size; sized up, the knobs stay at the bottom, the levels fill the height and the curve sits in its middle', compSmall.box.w > 320 && compSmall.box.h > 200 && compSmall.inside && compLarge.box.h > compSmall.box.h + 100 && compLarge.inside && compLarge.box.y + compLarge.box.h - compLarge.last.y < 100 && bigLevels.h > compSmall.levels.h + 100 && bigCurve.h === bigCurve.w && Math.abs(bigCurve.y + bigCurve.h / 2 - (bigLevels.y + bigLevels.h / 2)) <= 1, JSON.stringify({ compSmall, compLarge }), ); // Read again: Dock moved right with the window's edge. floatedComp = await compWindow(); await click(floatedComp.dock.x, floatedComp.dock.y); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`); const compLinked = await json('__gm.compressor.knobs()'); // Not the sound FX graph: the node there listens to the same taps (#380). await showTab('scene'); await sleep(100); const compUnseen = await json('__gm.compressor.levels()'); 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-comp'); graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13', 'fx-e14'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); const compDocsAfter = await json('__gm.documents()'); check( 'a linked input greys its knob, an unseen tab feeds no analyser, and the tab goes with its node', !compLinked.ratio.enabled && compLinked.threshold.enabled && compUnseen.taps.listened === '' && !compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')), JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }), ); // #379, #380: a Compressor node is drawn as its device – its inputs on the left, its output on the // right, the panel between them. Its knobs and its lever work right in the graph, and // played, the meter's needle swings and the LEDs light. A double-click opens the tab of #378. await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))}, ${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))}, );`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const hwPanel = () => json("__gm.compressor.panel('fx-hw')"); const hwShown = await hwPanel(); const hwRect = hwShown.rect; const hwInputs = Object.values(hwShown.inputs); const hwOutputs = Object.values(hwShown.outputs); const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h; check( 'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right, its numbers as plain labels, the ratio as 8:1', hwShown.shown && hwShown.numbers.ratio === '8:1' && hwShown.numbers.threshold === '-50' && hwShown.size.w === 820 && hwShown.size.h === 280 && hwInputs.length === 8 && hwInputs.every((p) => p.x < hwRect.x && besideThePanel(p)) && hwOutputs.length === 1 && hwOutputs.every((p) => p.x > hwRect.x + hwRect.w && besideThePanel(p)) && hwShown.node.w > hwRect.w && hwShown.node.h > hwRect.h, JSON.stringify(hwShown), ); const hwThreshold = hwShown.dials.threshold; await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20); const hwNumbers = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-hw').params`, ); const hwDragged = await hwNumbers(); await press('z', { ctrl: true }); const hwUndone = await hwNumbers(); const hwRatio = hwShown.dials.ratio; await t.wheel(hwRatio.x, hwRatio.y, -100); const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() }; await evaluate('__gm.soundfx.fit()'); await sleep(100); const hwRefit = (await hwPanel()).dials.ratio; await t.doubleClick(hwRefit.x, hwRefit.y); const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') }; await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y); await sleep(100); const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() }; check( 'in the node, a knob turns with a drag up as one undo step, the wheel over it zooms the graph and leaves it, a double-click resets it without opening the tab, and the lever switches auto-gain', hwDragged.threshold > -50 && hwUndone.threshold === -50 && hwWheeled.numbers.ratio === 8 && hwWheeled.panel.rect.w > hwRect.w + 10 && hwReset.numbers.ratio === 4 && hwReset.docs.active === `asset:${fxId}` && hwLever.numbers.autoGain === true && hwLever.panel.autoGain.on && /^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says), JSON.stringify({ hwDragged, hwUndone, hwWheeled, hwReset, hwLever }), ); await press('z', { ctrl: true }); await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.soundfx.play()'); const hwPlayed = await waitFor(hwPanel, (p) => p.seen.needle > 3 && p.seen.leds > 0); await shot('34-compressor-node'); check( 'played, the needle in the node swings towards what is taken away and the LEDs light with the level going out', hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd), JSON.stringify(hwPlayed), ); await evaluate('__gm.sound.stopAll()'); const hwTitle = await json("__gm.soundfx.nodeAt('fx-hw')"); await t.doubleClick(hwTitle.x, hwTitle.y); await sleep(100); const hwTab = { docs: await json('__gm.documents()'), windows: await json('__gm.floatWindows()'), shown: await json('__gm.compressor.shown()'), }; // The tab and the node listen to the same taps: the tab, open but not seen, must leave them be. await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); await sleep(100); await evaluate('__gm.soundfx.play()'); const hwBesideTab = await waitFor(hwPanel, (p) => p.seen.needle > 3); await evaluate('__gm.sound.stopAll()'); check( 'a double-click on the node opens its tab in the row, and with the tab open the node’s needle still swings', hwTab.docs.active === `compressor:${fxId}:fx-hw` && hwTab.windows.length === 0 && hwTab.shown?.nodeId === 'fx-hw' && hwBesideTab.seen.needle > 3, JSON.stringify({ hwTab, seen: hwBesideTab.seen }), ); // #384: a Gain node is drawn as an orange device with its one knob, worked as the Compressor's. // The Compressor's node is wide enough to lie over the Gain's: the Gain goes below it here. const gainUnder = await json("__gm.compressor.panel('fx-hw').node"); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.find((n) => n.id === 'fx-gain').y = 400;`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const gainPanel = () => json("__gm.gain.panel('fx-gain')"); const gainShown = await gainPanel(); const gainRect = gainShown.rect; const gainBeside = (p) => p.y > gainRect.y && p.y < gainRect.y + gainRect.h; const gainDb = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-gain').params.gain`, ); const gainDial = gainShown.knob.dial; await drag(gainDial.x, gainDial.y, gainDial.x, gainDial.y - 20); const gainDragged = await gainDb(); await press('z', { ctrl: true }); const gainUndone = await gainDb(); await t.wheel(gainDial.x, gainDial.y, -100); const gainWheeled = { db: await gainDb(), panel: await gainPanel() }; await evaluate('__gm.soundfx.fit()'); await sleep(100); const gainRefit = (await gainPanel()).knob.dial; await t.doubleClick(gainRefit.x, gainRefit.y); const gainReset = { db: await gainDb(), docs: await json('__gm.documents()') }; await press('z', { ctrl: true }); check( 'a Gain node shows its panel with one knob between its ports; a drag turns it as one undo step, the wheel zooms the graph, a double-click resets it without a tab', gainShown.shown && gainShown.size.w === 160 && gainShown.size.h === 160 && gainShown.knob.number === '-6' && gainShown.knob.enabled && Object.values(gainShown.inputs).length === 3 && Object.values(gainShown.inputs).every((p) => p.x < gainRect.x && gainBeside(p)) && Object.values(gainShown.outputs).every((p) => p.x > gainRect.x + gainRect.w && gainBeside(p)) && gainDragged > -6 && gainUndone === -6 && gainWheeled.db === -6 && gainWheeled.panel.rect.w > gainRect.w + 5 && gainReset.db === 0 && gainReset.docs.active === `asset:${fxId}`, JSON.stringify({ gainUnder, gainShown, gainDragged, gainUndone, gainWheeled, gainReset }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.find((n) => n.id === 'fx-gain').y = 0;`); 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-hw'); graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); // #389: a Reverb node is drawn as a device with a round screen of its tail. A knob its type does // not take goes over to Custom with the type's numbers, as one undo step; a sound picked on // it makes the type Sound and the screen draws that sound's tail. It is wired to nothing. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-rv', 'SoundFxReverb', 0, 400, { room: 'hall' }))});`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const reverbPanel = () => json("__gm.reverb.panel('fx-rv')"); const reverbParams = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-rv').params`, ); const rvShown = await reverbPanel(); const rvRect = rvShown.rect; const rvBeside = (p) => p.y > rvRect.y && p.y < rvRect.y + rvRect.h; const rvDial = rvShown.knobs.decay.dial; await drag(rvDial.x, rvDial.y, rvDial.x, rvDial.y - 20); const rvTurned = { params: await reverbParams(), panel: await reverbPanel() }; await press('z', { ctrl: true }); const rvUndone = await reverbParams(); await click(rvShown.rooms.cave.x, rvShown.rooms.cave.y); const rvCave = { params: await reverbParams(), panel: await reverbPanel() }; await click(rvCave.panel.sound.at.x, rvCave.panel.sound.at.y); const rvOffered = (await json('__gm.menuRows()')) ?? []; const rvPick = rvOffered.find((row) => row); const rvPickAt = await t.locate(rvPick, true); await click(rvPickAt.x, rvPickAt.y); const rvSound = { params: await reverbParams(), panel: await waitFor(reverbPanel, (p) => p.scope.kind === 'sound'), }; await press('z', { ctrl: true }); const rvSoundUndone = await reverbParams(); check( 'a Reverb node shows its device between its ports; a knob of a room goes to Custom with its numbers as one undo step, a type button switches, a picked sound makes it Sound and draws that sound', rvShown.shown && rvShown.size.w === 640 && rvShown.size.h === 280 && rvShown.room === 'hall' && ['decay', 'damping', 'predelay', 'impulse'].every((key) => rvShown.idle.includes(key)) && Object.keys(rvShown.inputs).join() === 'in,mix' && Object.values(rvShown.inputs).every((p) => p.x < rvRect.x && rvBeside(p)) && Object.values(rvShown.outputs).every((p) => p.x > rvRect.x + rvRect.w && rvBeside(p)) && rvShown.scope.kind === 'made' && rvShown.scope.reach > 0.48 && rvShown.scope.reach < 0.5 && rvShown.scope.says.ends === 'ENDS 2.52 s' && rvShown.highs === 'HIGHS 5.5 kHz' && rvTurned.params.room === 'custom' && rvTurned.params.decay > 2.5 && rvTurned.params.damping === 0.35 && rvTurned.params.predelay === 0.02 && Object.keys(rvTurned.panel.inputs).length === 5 && rvTurned.panel.idle.join() === 'impulse' && rvTurned.panel.scope.reach > rvShown.scope.reach && rvUndone.room === 'hall' && rvUndone.decay === undefined && rvCave.params.room === 'cave' && rvCave.panel.scope.reach > 0.68 && Boolean(rvPick) && rvSound.params.room === 'sound' && Boolean(rvSound.params.impulse) && rvSound.panel.sound.text === rvPick && rvSound.panel.scope.kind === 'sound' && rvSound.panel.scope.rings > 0 && rvSoundUndone.room === 'cave' && rvSoundUndone.impulse === undefined, JSON.stringify({ rvShown, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }), ); 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-rv');`); // #390: a Pitch is drawn as the Gain's device in green, a Mix in blue with a lever for its curve. // The Pitch in the chain has its semitones wired, so its knob is locked; a free one turns. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push( ${JSON.stringify(node('fx-pt', 'SoundFxPitch', 0, 400, { semitones: -12 }))}, ${JSON.stringify(node('fx-mx', 'SoundFxMix', 400, 400, { mix: 0.3 }))}, );`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const knobParams = (id) => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === '${id}').params`, ); const ptWired = await json("__gm.pitch.panel('fx-pitch')"); const ptShown = await json("__gm.pitch.panel('fx-pt')"); await drag(ptShown.knob.dial.x, ptShown.knob.dial.y, ptShown.knob.dial.x, ptShown.knob.dial.y - 20); const ptTurned = (await knobParams('fx-pt')).semitones; await press('z', { ctrl: true }); const ptUndone = (await knobParams('fx-pt')).semitones; const mxShown = await json("__gm.mix.panel('fx-mx')"); const mxRect = mxShown.rect; await click(mxShown.lever.at.x, mxShown.lever.at.y); const mxBoth = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") }; await press('z', { ctrl: true }); const mxUndone = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") }; await drag(mxShown.knob.dial.x, mxShown.knob.dial.y, mxShown.knob.dial.x, mxShown.knob.dial.y - 20); const mxTurned = (await knobParams('fx-mx')).mix; await press('z', { ctrl: true }); const mxTurnUndone = (await knobParams('fx-mx')).mix; check( 'a Pitch node is a green one-knob device, locked while its semitones are wired; a Mix node is a blue one whose lever switches the curve, each change one undo step', ptWired.size.w === 160 && !ptWired.knob.enabled && ptWired.lever === null && ptShown.knob.enabled && ptShown.knob.number === '-12' && ptTurned > -12 && ptUndone === -12 && mxShown.size.w === 180 && mxShown.size.h === 200 && mxShown.knob.number === '0.3' && // The lever stands above the knob, both in the middle of the plate. mxShown.lever.at.y < mxShown.knob.dial.y && Math.abs(mxShown.lever.at.x - mxShown.knob.dial.x) < 1 && Object.values(mxShown.inputs).length === 3 && Object.values(mxShown.inputs).every((p) => p.x < mxRect.x) && Object.values(mxShown.outputs).every((p) => p.x > mxRect.x + mxRect.w) && mxShown.lever.on === false && mxShown.lever.says === 'crossfade' && mxBoth.params.curve === 'both' && mxBoth.panel.lever.on && mxBoth.panel.lever.says === 'both at center' && mxUndone.params.curve !== 'both' && mxUndone.panel.lever.says === 'crossfade' && mxTurned > 0.3 && mxTurnUndone === 0.3, JSON.stringify({ ptWired, ptShown, ptTurned, ptUndone, mxShown, mxBoth, mxUndone, mxTurned, mxTurnUndone, }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => !['fx-pt', 'fx-mx'].includes(n.id));`); // #392: a Pan is the same device in cyan, its 0 straight up, L and R at the ends of the turn. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-pn', 'SoundFxPan', 0, 400, { pan: -0.35 }))});`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const pnShown = await json("__gm.pan.panel('fx-pn')"); await drag(pnShown.knob.dial.x, pnShown.knob.dial.y, pnShown.knob.dial.x, pnShown.knob.dial.y - 20); const pnTurned = (await knobParams('fx-pn')).pan; await press('z', { ctrl: true }); const pnUndone = (await knobParams('fx-pn')).pan; check( 'a Pan node is a cyan one-knob device whose knob turns the pan, one undo step', pnShown.size.w === 160 && pnShown.size.h === 160 && pnShown.lever === null && pnShown.knob.enabled && pnShown.knob.number === '-0.35' && Object.values(pnShown.inputs).every((p) => p.x < pnShown.rect.x) && Object.values(pnShown.outputs).every((p) => p.x > pnShown.rect.x + pnShown.rect.w) && pnTurned > -0.35 && pnTurned <= 1 && pnUndone === -0.35, JSON.stringify({ pnShown, pnTurned, pnUndone }), ); 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-pn');`); // #393: a Delay is a device of its own size with its echoes on a screen and a light running over // them at their beat. Ping-pong puts them left and right by turns, Sync names the time as a // note, the time knob turns it; each is one undo step. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-dl', 'SoundFxDelay', 0, 400, { time: 0.5, feedback: 0.5, mix: 0.5 }))});`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const dlShown = await json("__gm.delay.panel('fx-dl')"); await sleep(150); const dlLater = (await json("__gm.delay.panel('fx-dl')")).scope.at; await click(dlShown.pingPong.at.x, dlShown.pingPong.at.y); const dlPingPong = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; await click(dlShown.sync.at.x, dlShown.sync.at.y); const dlBeats = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; const dlTime = dlShown.knobs.time.dial; await drag(dlTime.x, dlTime.y, dlTime.x, dlTime.y - 20); const dlTurned = (await knobParams('fx-dl')).time; for (let i = 0; i < 3; i++) await press('z', { ctrl: true }); const dlUndone = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; const dlSides = (panel) => panel.scope.echoes .slice(0, 3) .map((e) => e.side) .join(' '); check( 'a Delay node is a device with its echoes on a screen and a running light; ping-pong, sync and the time knob are one undo step each', dlShown.size.w === 680 && dlShown.size.h === 280 && Object.values(dlShown.inputs).length === 5 && Object.values(dlShown.inputs).every((p) => p.x < dlShown.rect.x) && Object.values(dlShown.outputs).every((p) => p.x > dlShown.rect.x + dlShown.rect.w) && dlShown.knobs.time.number === '500 ms' && dlShown.scope.heard === 10 && dlShown.scope.echoes.length === 10 && dlShown.scope.says.every === 'EVERY 500 ms' && dlShown.scope.says.ends === 'ENDS 5 s' && dlSides(dlShown) === 'both both both' && // The light runs on by itself. dlLater !== dlShown.scope.at && dlPingPong.params.pingPong === true && dlPingPong.panel.pingPong.lit && dlSides(dlPingPong.panel) === 'left right left' && dlBeats.params.sync === 'beats' && dlBeats.panel.sync.says === 'beats' && dlBeats.panel.knobs.time.number === '1/8' && dlBeats.panel.scope.says.every === 'EVERY 1/8 · 250 ms AT 120 BPM' && dlTurned > 0.5 && dlUndone.params.time === 0.5 && dlUndone.params.sync !== 'beats' && dlUndone.params.pingPong !== true && dlUndone.panel.knobs.time.number === '500 ms' && dlSides(dlUndone.panel) === 'both both both', JSON.stringify({ dlShown, dlLater, dlPingPong, dlBeats, dlTurned, dlUndone }), ); 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-dl');`); // #396: a Saturation is a device of its own size with the wave a sine comes out as on a screen, // glowing hotter the more it is driven. A type button, the auto-gain lever and the drive // knob are one undo step each. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-st', 'SoundFxSaturation', 0, 400, { drive: 12 }))});`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const stShown = await json("__gm.saturation.panel('fx-st')"); await sleep(150); const stLater = (await json("__gm.saturation.panel('fx-st')")).scope.flare; await click(stShown.types.hard.x, stShown.types.hard.y); const stHard = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") }; await click(stShown.autoGain.at.x, stShown.autoGain.at.y); const stOff = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") }; const stDrive = stShown.knobs.drive.dial; await drag(stDrive.x, stDrive.y, stDrive.x, stDrive.y - 40); const stHot = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") }; for (let i = 0; i < 3; i++) await press('z', { ctrl: true }); const stUndone = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") }; check( 'a Saturation node is a device whose screen glows hotter with the drive; a type, auto-gain and the drive knob are one undo step each', stShown.size.w === 720 && stShown.size.h === 280 && Object.values(stShown.inputs).length === 5 && Object.values(stShown.inputs).every((p) => p.x < stShown.rect.x) && Object.values(stShown.outputs).every((p) => p.x > stShown.rect.x + stShown.rect.w) && stShown.type === 'soft' && stShown.knobs.drive.number === '12 dB' && stShown.heat.word === 'WARM' && stShown.heat.burning && stShown.autoGain.lit && stShown.scope.says.auto.startsWith('AUTO-GAIN ') && // The glow flickers by itself. stLater !== stShown.scope.flare && stHard.params.curve === 'hard' && stHard.panel.type === 'hard' && stOff.params.autoGain === false && stOff.panel.scope.says.auto === 'AUTO-GAIN OFF' && stHot.params.drive > 12 && stHot.panel.heat.share > stShown.heat.share && stHot.panel.scope.embers > 0 && stUndone.params.drive === 12 && stUndone.params.curve !== 'hard' && stUndone.params.autoGain !== false && stUndone.panel.type === 'soft' && stUndone.panel.heat.word === 'WARM', JSON.stringify({ stShown, stLater, stHard, stOff, stHot, stUndone }), ); 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-st');`); // #397: a Limiter is a device of its own size whose screen catches what it takes away from the // moment a sound starts and holds it once the sound ends. Wired in before the output, the // most it caught is the loudest that came in over its limit. The lever and the threshold // knob are one undo step each and leave the capture held. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-lm', 'SoundFxLimiter', 0, 400, { threshold: -20 }))}); const last = graph.edges.find((e) => e.to.node === 'fx-out' && e.to.port === 'in'); graph.edges.push({ id: 'fx-e-lm', from: { node: 'fx-lm', port: 'out' }, to: { node: 'fx-out', port: 'in' } }); last.to = { node: 'fx-lm', port: 'in' };`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const lmPanel = () => json("__gm.limiter.panel('fx-lm')"); const lmShown = await lmPanel(); await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.soundfx.play()'); const lmHeld = await waitFor(lmPanel, (p) => p.scope.state === 'held', 40); await click(lmShown.autoGain.at.x, lmShown.autoGain.at.y); const lmOff = { params: await knobParams('fx-lm'), panel: await lmPanel() }; const lmDial = lmShown.knobs.threshold.dial; await drag(lmDial.x, lmDial.y, lmDial.x, lmDial.y - 20); const lmTurned = (await knobParams('fx-lm')).threshold; for (let i = 0; i < 2; i++) await press('z', { ctrl: true }); const lmUndone = { params: await knobParams('fx-lm'), panel: await lmPanel() }; check( 'a Limiter node holds what it took away once the sound ends, the most of it the loudest over its limit; its lever and threshold are one undo step each', lmShown.size.w === 720 && lmShown.size.h === 280 && Object.values(lmShown.inputs).length === 5 && Object.values(lmShown.inputs).every((p) => p.x < lmShown.rect.x) && Object.values(lmShown.outputs).every((p) => p.x > lmShown.rect.x + lmShown.rect.w) && lmShown.knobs.threshold.number === '-20 dB' && lmShown.autoGain.lit && lmShown.autoGain.says === '+19 dB' && lmShown.status === 'WAITING FOR SOUND' && lmHeld.status === 'HELD' && lmHeld.scope.limited > 0 && lmHeld.scope.most !== null && lmHeld.scope.loudest > -20 && Math.abs(lmHeld.scope.most.db - (lmHeld.scope.loudest + 20)) < 1 && /^MOST -\d+(\.\d)? dB AT \d+ ms$/.test(lmHeld.scope.says.most) && lmHeld.scope.says.side.startsWith('LASTED ') && !lmHeld.lamp && lmOff.params.autoGain === false && lmOff.panel.autoGain.says === 'off' && lmOff.panel.scope.state === 'held' && lmTurned > -20 && lmUndone.params.threshold === -20 && lmUndone.params.autoGain !== false && lmUndone.panel.scope.state === 'held' && lmUndone.panel.scope.most?.db === lmHeld.scope.most.db, JSON.stringify({ lmShown, lmHeld, lmOff, lmTurned, lmUndone }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; const into = graph.edges.find((e) => e.to.node === 'fx-lm' && e.to.port === 'in'); into.to = { node: 'fx-out', port: 'in' }; graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm'); graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`); // #398: a Tremolo, a Vibrato, a Phaser and a Flanger are devices of their own size whose screens // draw what they do over the first seconds of a sound, a light playing it by itself. A // button and a knob are one undo step each, and the screen follows them. const swinging = [ { kind: 'tremolo', type: 'SoundFxTremolo', size: [640, 280], inputs: 4, pick: 'square', knob: 'depth', shown: (p) => p.knobs.rate.number === '5 Hz' && p.side === 'DOWN TO 50%' && p.picked === 'sine', turned: (p) => p.side !== 'DOWN TO 50%' && p.picked === 'square', }, { kind: 'vibrato', type: 'SoundFxVibrato', size: [640, 280], inputs: 4, pick: 'triangle', knob: 'depth', shown: (p) => p.side === 'UP 0.5 st DOWN 0.51 st' && p.scope.says.right === 'LATE 0.93 ms', turned: (p) => p.scope.highest > 0.5 && p.picked === 'triangle', }, { kind: 'phaser', type: 'SoundFxPhaser', size: [760, 280], inputs: 6, pick: '8', knob: 'frequency', shown: (p) => p.side === '2 NOTCHES' && p.scope.notches.length === 2, turned: (p) => p.side === '4 NOTCHES' && p.scope.notches.length === 4 && p.scope.highest > 1600, }, { kind: 'flanger', type: 'SoundFxFlanger', size: [680, 280], inputs: 7, pick: null, knob: 'sweep', shown: (p) => p.side === 'DELAY 1 ms TO 3 ms' && p.scope.says.right === 'NO SWEEP', turned: (p) => p.scope.says.right.startsWith('SWEEP +'), }, ]; for (const device of swinging) { const id = `fx-${device.kind}`; const panel = () => json(`__gm.${device.kind}.panel('${id}')`); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node(id, device.type, 0, 400, {}))});`); await evaluate('__gm.soundfx.fit()'); await sleep(100); const shown = await panel(); await sleep(150); const later = (await panel()).scope.at; if (device.pick) await click(shown.picks[device.pick].x, shown.picks[device.pick].y); const dial = shown.knobs[device.knob].dial; await drag(dial.x, dial.y, dial.x, dial.y - 20); const turned = { params: await knobParams(id), panel: await panel() }; for (let i = 0; i < (device.pick ? 2 : 1); i++) await press('z', { ctrl: true }); const undone = { params: await knobParams(id), panel: await panel() }; check( `a ${device.kind} node is a device whose screen plays what it does; a button and a knob are one undo step each`, shown.size.w === device.size[0] && shown.size.h === device.size[1] && Object.values(shown.inputs).length === device.inputs && Object.values(shown.inputs).every((p) => p.x < shown.rect.x) && Object.values(shown.outputs).every((p) => p.x > shown.rect.x + shown.rect.w) && device.shown(shown) && shown.scope.moving && // The light runs on by itself. later !== shown.scope.at && turned.params[device.knob] !== undefined && device.turned(turned.panel) && Object.keys(undone.params).length === 0 && device.shown(undone.panel), JSON.stringify({ shown, later, turned, undone }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== ${JSON.stringify(id)});`); } 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: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain }); const makeup = (autoGain) => __gm.sound.compressorMakeup({ threshold: -24, knee: 0, ratio: 4, makeup: 0, autoGain }); const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain }); const limited = await render(limit(0), sine(2)); // A sine at the -18 dB auto-gain keeps as loud as it came in (#378). const reference = sine(10 ** (-18 / 20)); return { compressorLate: loudest(await render(compress(1), impulse)) - 480, limiterLate: loudest(await render(limit(0), impulse)) - 480, autoGain: db(await render(compress(makeup(true)), reference)), noMakeup: db(await render(compress(makeup(false)), reference)), 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 keeps -18 dB as loud, a Limiter is 5 ms late and never passes its limit', dynamics.compressorLate === 288 && dynamics.limiterLate === 240 && Math.abs(dynamics.autoGain + 18) < 0.5 && Math.abs(dynamics.noMakeup + 22.5) < 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'); // #386: the Laser's Chip is drawn as a device between its ports: a button per wave, the "follow // note" lever and a knob per number, each change one undo step, and its screens drawn from // it. A Laser falls: its pitch ends lower than it starts. Three undos bring the Laser back. await clickText('Fit'); await sleep(300); const chipId = ( await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentChip')`, ) ).id; const chipPanel = () => json(`__gm.chip.panel('${chipId}')`); const chipShown = await chipPanel(); const chipRect = chipShown.rect; const chipBeside = (p) => p.y > chipRect.y && p.y < chipRect.y + chipRect.h; await click(chipShown.waves.triangle.x, chipShown.waves.triangle.y); const chipPicked = { params: (await insGraph()).chip, panel: await chipPanel() }; await click(chipShown.follow.at.x, chipShown.follow.at.y); const chipFollowed = { params: (await insGraph()).chip, panel: await chipPanel() }; const slideDial = chipShown.knobs.slide.dial; await drag(slideDial.x, slideDial.y, slideDial.x, slideDial.y - 20); const chipSlid = (await insGraph()).chip; for (let i = 0; i < 3; i++) await press('z', { ctrl: true }); const chipUndone = (await insGraph()).chip; check( 'a Chip node shows its device between its ports; a wave button, the lever and a knob each change it as one undo step, and its screens follow', chipShown.shown && chipShown.size.w === 840 && chipShown.size.h === 280 && Object.values(chipShown.inputs).length === 12 && Object.values(chipShown.inputs).every((p) => p.x < chipRect.x && chipBeside(p)) && Object.values(chipShown.outputs).every((p) => p.x > chipRect.x + chipRect.w && chipBeside(p)) && chipShown.wave === laser.chip.wave && chipShown.follow.on === false && chipShown.knobs.slide.value === laser.chip.slide && chipShown.pitch.from.startsWith(chipShown.knobs.frequency.number) && chipShown.pitch.last < 0 && chipPicked.params.wave === 'triangle' && chipPicked.panel.waveScreen === 'TRIANGLE' && chipPicked.panel.idle.includes('duty') && chipFollowed.params.followNote === true && chipFollowed.panel.follow.on && chipFollowed.panel.idle.includes('frequency') && // A Laser's "min frequency" may lie above A4: then it says the note ends at once. chipFollowed.panel.pitch.from.startsWith('A4 440 Hz') && chipSlid.slide > laser.chip.slide && JSON.stringify(chipUndone) === JSON.stringify(laser.chip), JSON.stringify({ chipShown, chipPicked, chipFollowed, slide: [laser.chip.slide, chipSlid.slide], chipUndone, }), ); // #388: the Laser's Envelope is drawn as a device between its ports, its curve on a screen. Its // sustain is 0, so the curve ends with the decay; nothing takes its value, so "from" and // "to" are grey. A knob and a scrubbed field each change it as one undo step. const envelopeId = ( await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentEnvelope')`, ) ).id; const envelopePanel = () => json(`__gm.envelope.panel('${envelopeId}')`); const envShown = await envelopePanel(); const envRect = envShown.rect; const envBeside = (p) => p.y > envRect.y && p.y < envRect.y + envRect.h; const decayDial = envShown.knobs.decay.dial; await drag(decayDial.x, decayDial.y, decayDial.x, decayDial.y - 20); const envTurned = { params: (await insGraph()).envelope, panel: await envelopePanel() }; const toField = envShown.fields.to.at; await drag(toField.x, toField.y, toField.x + 30, toField.y); const envScrubbed = { params: (await insGraph()).envelope, panel: await envelopePanel() }; for (let i = 0; i < 2; i++) await press('z', { ctrl: true }); const envUndone = (await insGraph()).envelope; check( 'an Envelope node shows its device between its ports with its curve; a knob and a field each change it as one undo step', envShown.shown && envShown.size.w === 520 && envShown.size.h === 280 && Object.values(envShown.inputs).length === 7 && Object.values(envShown.inputs).every((p) => p.x < envRect.x && envBeside(p)) && Object.values(envShown.outputs).every((p) => p.x > envRect.x + envRect.w && envBeside(p)) && envShown.trace.ends && envShown.trace.stages.map((s) => s.key).join() === 'attack,decay' && envShown.screen.ends === 'NOTE ENDS' && envShown.range === '' && envShown.idle.includes('from') && envShown.idle.includes('to') && envShown.knobs.decay.value === laser.envelope.decay && envTurned.params.decay > laser.envelope.decay && envTurned.panel.knobs.decay.value === envTurned.params.decay && envTurned.panel.trace.stages[1].seconds === envTurned.params.decay && envScrubbed.params.to > 1 && envScrubbed.panel.fields.to.text === String(envScrubbed.params.to) && JSON.stringify(envUndone) === JSON.stringify(laser.envelope), JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }), ); 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 }), ); // #377: at 1000 ms a frame after the wheel the zoom is on its way, on the level scale and on the wave alike. const waveZoom = (key) => `({ scale: __gm.soundTab.zoom().${key}, moving: __gm.soundTab.zoom().moving })`; const waveLevel = await sampleZoom(t, levelScale, waveZoom('level')); const waveTime = await sampleZoom(t, await json('__gm.soundTab.at(0.5)'), waveZoom('pxPerSecond')); check( 'a zoom of the height and of the time is shown on its way and lands where the jump does', waveLevel.ok && waveTime.ok, `${waveLevel.detail} ${waveTime.detail}`, ); // 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: [] };`); // 34h-4. A long sound streamed (#349): five minutes of mono, decoded whole some 55 MB. With Stream // on, the editor makes a copy of it as Opus and a game plays from that, through its bus and // the master; the renderer grows by a few megabytes while it plays, not by the decoded // file. The counter-proof right after turns Stream off: decoded, it grows by about that much. const long = await uploadSound(t.baseUrl, sineWav(300, 330), 'long.wav'); // The tab's renderer is by far the largest. Summed, a renderer Chrome starts meanwhile counted too. const renderers = () => execSync('ps -eo rss,args') .toString() .split('\n') .filter((line) => line.includes('--type=renderer')) .map((line) => Number(line.trim().split(/\s+/)[0]) / 1024); const rendererMb = () => Math.max(...renderers()); const restingMb = async () => { await t.cdp.send('HeapProfiler.collectGarbage'); await sleep(1500); return rendererMb(); }; // What the game runs: On start plays the long sound, or a song. const startWith = (played) => mutate(` __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph = ${JSON.stringify({ nodes: [node('sn-start', 'OnStart', 0, 0), played], edges: [edge('sn-e1', 'sn-start', 'exec', played.id, 'exec')], })};`); const playsLong = node('sn-play', 'PlaySound', 260, 0, { sound: 'sn-long', volume: 1, loop: '' }); await evaluate('__gm.sound.stopAll()'); await mutate(` const p = __gm.state.project; p.assets.push( { id: 'sn-long', type: 'sound', name: 'long', props: { volume: 1, loop: true, fx: '', stream: true }, file: ${JSON.stringify(long.file)} }, { id: 'sn-bus', type: 'bus', name: 'Music', props: { volume: 1, fx: '' } }, ); p.routing = { nodes: [ { id: 'sn-long', type: 'RoutedAsset', x: 0, y: 0, params: { name: 'long', kind: 'sound' } }, { id: 'sn-bus', type: 'RoutedAsset', x: 320, y: 0, params: { name: 'Music', kind: 'bus' } }, { id: '#master', type: 'RoutedMaster', x: 640, y: -80, params: {} }, ], edges: [ { id: 'sn-r1', from: { node: 'sn-long', port: 'out' }, to: { node: 'sn-bus', port: 'in' } }, { id: 'sn-r2', from: { node: 'sn-bus', port: 'out' }, to: { node: '#master', port: 'in' } }, ], };`); await startWith(playsLong); await evaluate('__gm.streams.idle()'); const copies = await json("__gm.streams.copies('sn-long')"); const streamed = (await json('__gm.streams.table()')).find((entry) => entry.name === long.file); check( 'with Stream on the editor makes a copy of the long sound as Opus, five minutes to the sample, and a game streams it from there', copies?.sound?.from === long.file && copies.sound.frames === 300 * 48000 && copies.sound.bytes > 0 && streamed?.file === copies.sound.file && streamed.frames === 300 * 48000, JSON.stringify({ long, copies, streamed }), ); const statsBefore = await stats(); const decodedBefore = statsBefore.decoded; const restStreamed = await restingMb(); const renderersBefore = renderers(); await play(); const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']); await json(`__gm.routing.peaks(${tapsLong})`); const streaming = await waitFor(stats, (s) => s.streaming.playing > 0, 40); const heardLong = await waitFor( () => json(`__gm.routing.peaks(${tapsLong})`), (peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01, ); await sleep(1000); // After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614). const playingStreamed = await restingMb(); const renderersPlaying = renderers(); const heldStreamed = await stats(); await t.clickToolbar('pause'); await sleep(500); const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') }; await sleep(500); const pausedLater = await json('__gm.sound.clock()'); await t.clickToolbar('pause'); await sleep(1000); const resumedLong = await stats(); await stop(); const stoppedLong = await stats(); check( 'played, the streamed sound is heard on its bus and the master, none of the file decoded', streaming?.last?.file === long.file && streaming.last.bus === 'mix:sn-bus' && streaming.decoded === decodedBefore && Math.max(...(heardLong?.mix ?? [0])) > 0.01, JSON.stringify({ streaming, heardLong }), ); check( 'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it', paused.state === 'suspended' && pausedLater === paused.clock && resumedLong.streaming.playing === 1 && resumedLong.streaming.skipped === 0 && stoppedLong.streaming.playing === 0 && stoppedLong.playing === 0, JSON.stringify({ paused, pausedLater, resumedLong: resumedLong.streaming, stoppedLong }), ); // The counter-proof: Stream off, the game plays it decoded, and the renderer holds all of it. // Measured from before it is switched off, as from then on the editor decodes it ahead, and before // anything below has had large buffers made: Chrome does not hand all of that back at once. await evaluate('__gm.sound.stopAll()'); const restDecoded = await restingMb(); await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`); await play(); const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60); await sleep(1000); const playingDecoded = await restingMb(); await stop(); await evaluate('__gm.streams.idle()'); check( 'with Stream off the same sound is decoded whole, and the renderer grows by more than 40 MB', decodedNow?.streaming.playing === 0 && decodedNow.decoded > decodedBefore && playingDecoded - restDecoded > 40 && (await json("__gm.streams.copies('sn-long')"))?.sound === null, JSON.stringify({ restDecoded, playingDecoded, decodedNow }), ); // What the sound library holds of it, counted by the library: streamed at most 4 s sent ahead on top // of what it held before (its head among the heads of every copy), decoded all five minutes: 50 MB // at 44.1 kHz. // Not the growth of the renderer: after the modules before this one it grew by 25 to 66 MB while // streaming, alone by 1 to 8 (#378, CI tasks 611 to 617). That growth stays in the log. const mb = (bytes) => bytes / 2 ** 20; const kept = { before: mb(statsBefore.streaming.held), streamed: mb(heldStreamed.streaming.held - statsBefore.streaming.held), streamedWhole: mb(heldStreamed.decodedBytes - statsBefore.decodedBytes), decodedWhole: mb((decodedNow?.decodedBytes ?? 0) - statsBefore.decodedBytes), }; check( 'streamed, the page holds a few seconds of its samples and none decoded whole, decoded all five minutes', kept.streamed > 0 && kept.streamed < 4 && kept.streamedWhole === 0 && kept.decodedWhole > 40, JSON.stringify({ kept, grownStreamed: playingStreamed - restStreamed, grownDecoded: playingDecoded - restDecoded, renderersBefore, renderersPlaying, }), ); // The Sound tab points a long sound at the switch, and says once it streams. await evaluate("__gm.state.openAsset('sn-long')"); await sleep(300); const longInfo = await json('__gm.soundTab.info()'); await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = true;`); await evaluate('__gm.streams.idle()'); await sleep(200); const streamedInfo = await json('__gm.soundTab.info()'); check( 'the Sound tab suggests Stream for a sound of a minute or more, and says when it is streamed', longInfo === '300.00 s · long, try Stream' && streamedInfo === '300.00 s · streamed', JSON.stringify({ longInfo, streamedInfo }), ); // On a track of a song it starts on its beat, a clip of its own streamed from a copy of its cut. await mutate(` const sound = __gm.state.project.assets.find((a) => a.id === 'sn-long'); sound.clips = [{ id: 'sn-long-clip', name: 'Middle', props: { start: 10, end: 20, fadeIn: 5, fadeOut: 5, fadeInCurve: 'linear', fadeOutCurve: 'linear', reverse: true } }];`); await clickText('+'); await pickAssetType(t, 'Song'); await sleep(200); const [longSong] = await assetsOfType('song'); await mutate(` const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(longSong.id)}); song.items = [{ id: 'sn-i1', track: song.tracks[0].id, kind: 'sound', asset: 'sn-long-clip', tick: 0, length: 3840, offset: 0, volume: 0, transpose: 0, instrument: '', fx: '' }];`); await evaluate('__gm.streams.idle()'); await startWith( node('sn-song', 'PlaySong', 260, 0, { song: longSong.id, volume: 1, fadeIn: 0, loop: false }), ); await showTab('scene'); await play(); const songRun = await waitFor( async () => ({ runs: await json('__gm.sound.playbacks()'), stats: await stats() }), (got) => got.runs[0]?.clips.length > 0 && got.stats.streaming.playing > 0, 40, ); await stop(); const clipCopy = (await json("__gm.streams.copies('sn-long')"))?.clips[0]?.stream; const clipName = await json("__gm.soundTab.played('sn-long-clip')"); const songClip = songRun?.runs[0]?.clips[0]; check( 'a clip of the streamed sound gets a copy of its cut, and a song streams it on its track, started on its beat', clipCopy?.from === clipName && clipCopy.frames === 10 * 48000 && songClip?.sound === 'sn-long-clip' && songRun.stats.last?.file === clipName && Math.abs(songRun.stats.last.at - songClip.at) < 1e-6, JSON.stringify({ clipCopy, clipName, songClip, last: songRun?.stats.last }), ); // Offline, to the sample: from 0.25 s on, two seconds into the copy; round the seam of its loop; // and the reversed clip from its start – each against the copy decoded whole. const exactly = async (...args) => JSON.parse(await evaluate(`__gm.streams.exact(...${JSON.stringify(args)}).then(JSON.stringify)`)); const exact = { start: await exactly(long.file, 2, false, 0.25, 2), seam: await exactly(long.file, 299, true, 0, 2.5), clip: await exactly(clipName, 9, true, 0.1, 2), }; check( 'streamed, it starts on its sample, runs on past where it jumped in and round its loop as the copy decoded whole', Object.values(exact).every( (run) => run !== null && run.own < 1e-6 && run.browser < 0.01 && run.skipped === 0 && run.decoded === run.frames, ), JSON.stringify(exact), ); // 34h-5. With Stream off the clip keeps its copy, and a game only playing the sound through it // decodes the copy, not the five minutes. The counter-proof plays the sound itself: then the // whole file is decoded ahead again. await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`); await evaluate('__gm.streams.idle()'); const keptCopies = await json("__gm.streams.copies('sn-long')"); const freshly = async () => JSON.parse(await evaluate(`__gm.streams.fresh(${JSON.stringify(clipName)}).then(JSON.stringify)`)); const fresh = await freshly(); check( 'with Stream off the clip keeps a copy of its cut, and a game decodes that rather than the whole file, as the cut sounds', keptCopies?.sound === null && keptCopies.clips[0]?.stream?.from === clipName && fresh?.ahead.includes(clipName) && !fresh.ahead.includes(long.file) && fresh.whole === false && fresh.decoded === fresh.ahead.length && fresh.played?.length === 10 * 48000 && fresh.played.wanted === fresh.played.length && fresh.played.level < 0.5 && fresh.played.worst < 0.1, JSON.stringify({ keptCopies, fresh }), ); await startWith(playsLong); const freshWhole = await freshly(); check( 'once the game plays the sound itself, its whole file is decoded ahead again', freshWhole?.ahead.includes(long.file) && !freshWhole.ahead.includes(clipName) && freshWhole.whole === true, JSON.stringify({ ahead: freshWhole?.ahead, whole: freshWhole?.whole }), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !['sn-long', 'sn-bus', ${JSON.stringify(longSong.id)}].includes(a.id)); 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')), ); }