Files
feuerwerk/tools/e2e/34-sound.mjs
T
adminandClaude Opus 5.5 852dce5142 Shrink a clip with the wave and drop it with a click beside (#357)
Zoomed out, the wave left room above and below it, but the bright file,
the picked clip and its handles still took the whole height. And a click
in the wave beside the picked clip kept it picked, so only the strip
could take it back to the sound.

- fileReach in waveView.ts is how far up and down the file reaches:
  full scale and its inset, never past the area. At level 1 it is the
  whole height, zoomed in too.
- The bright file, the shade outside the clip and the two handles are
  drawn within that reach.
- A click over the wave beside the picked clip picks the sound. Inside
  the clip or on a handle nothing changes, and a drag still spans the
  clip anew.

Tests:
- Unit wave-view: 11 green (1 new), a counter-check red
- e2e 34 (melektaus-atx): ALL PASSED, 91 checks (1 new)
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4195 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:13:09 +02:00

2319 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node, port, variable, written } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
/**
* Sound assets (#45): uploaded like a typeface – picked from what the server holds through
* "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing
* wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the
* node scales the one and can overrule the other in either direction.
*
* Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness
* is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the
* witness for a repaint that did not happen. No pixel check could see any of this.
*
* The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and
* a 0.75 s drone, told apart by their exact size rather than by their name.
*/
const LANE = LANES.sound;
const BLIP_BYTES = 4044;
const DRONE_BYTES = 12044;
export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
const stats = () => json('__gm.sound.stats()');
/** A voice starts only once its bytes have been decoded, so what it changes is awaited. */
const waitFor = async (read, done, tries = 25) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
// Every reading up to the one `done` takes: a check holds each against its own time on the audio
// clock, however late the first one comes in.
const trace = async (read, done, tries = 40) => {
const seen = [await read()];
for (let i = 0; i < tries && !done(seen.at(-1)); i++) {
await sleep(150);
seen.push(await read());
}
return seen;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 34a. What the server holds, straight from the API: the two files the seed put there.
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
check(
'the server holds the two seeded sounds, named by their content',
Boolean(blip && drone) && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(blip.file),
JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }),
);
if (!blip || !drone) return;
check(
'the listing carries the name each file was uploaded under',
blip.name === 'blip.wav' && drone.name === 'drone.wav',
JSON.stringify({ blip: blip.name, drone: drone.name }),
);
// A file of this run's own that no project uses, for the picker to delete (#344). Random bytes
// behind a WAV header: the store reads the signature only, and no earlier run has the same.
const spare = await evaluate(`(async () => {
const bytes = crypto.getRandomValues(new Uint8Array(48));
bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0);
bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8);
const r = await fetch('/game-maker/api/index.php?r=sounds&name=spare.wav', {
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes,
});
return (await r.json()).file;
})()`);
// 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface.
await clickText('+');
await pickAssetType(t, 'Sound');
await sleep(500);
const picker = await json('__gm.soundPicker()');
check(
'the + menu opens the sound picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0,
JSON.stringify(picker),
);
check(
'a row nobody uses yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav',
JSON.stringify(picker.captions),
);
await shot('34-sound-picker');
// 34b'. A file of one's own that no project uses goes from the picker (#344): the first press of
// Delete only asks, the second deletes it on the server.
const spareRow = await json(`__gm.soundPickerRow(${JSON.stringify(spare)})`);
await click(spareRow.x, spareRow.y);
const marked = await json('__gm.soundPicker()');
check(
'a file of one’s own says whose it is and may be deleted',
marked.selected === spare && marked.detail.includes('owner e2e') && marked.deleteButton.enabled,
JSON.stringify({ selected: marked.selected, spare, detail: marked.detail, button: marked.deleteButton }),
);
await click(marked.deleteButton.x, marked.deleteButton.y);
const armed = await json('__gm.soundPicker()');
await click(armed.deleteButton.x, armed.deleteButton.y);
const emptied = await waitFor(
() => json('__gm.soundPicker()'),
(p) => !p.files.includes(spare),
);
const served = await evaluate(`fetch('/game-maker/data/sounds/${spare}').then((r) => r.status)`);
check(
'the first press asks, the second deletes the file on the server',
armed.deleteButton.shown === 'Delete for good' &&
armed.files.includes(spare) &&
!emptied.files.includes(spare) &&
emptied.files.includes(blip.file) &&
emptied.detail === 'Deleted spare.wav.' &&
served === 404,
JSON.stringify({ armed: armed.deleteButton, left: emptied.files.length, detail: emptied.detail, served }),
);
// 34c. The ▶ at a row's left is a preview: it plays that file without choosing it.
const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`);
await click(playAt.x, playAt.y);
const previewed = await waitFor(stats, (s) => s.started > 0);
const afterPreview = await json('__gm.soundPicker()');
check(
'pressing a row’s ▶ plays that file and picks nothing',
previewed.started === 1 &&
previewed.last?.file === drone.file &&
afterPreview.played === drone.file &&
afterPreview.selected === null,
JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }),
);
const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`);
await click(row.x, row.y);
await clickText('Use sound');
const sounds = await assetsOfType('sound');
check(
'picking a row makes a sound asset of that file, named after the file rather than "Sound"',
sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip',
JSON.stringify(sounds),
);
if (sounds.length !== 1) return;
const soundId = sounds[0].id;
check(
'a fresh sound asset is full volume and one-shot',
sounds[0].props.volume === 1 && sounds[0].props.loop === false,
JSON.stringify(sounds[0].props),
);
// Closing the picker silences its preview; nothing it started may outlive the dialog.
check(
'closing the picker leaves nothing playing',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
// 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is.
await select({ kind: 'asset', assetId: soundId });
const volumeRow = await json("__gm.inspectorField('Volume', true)");
const loopRow = await json("__gm.inspectorField('Loop', true)");
check(
'a sound asset has a Volume row and a Loop row',
Boolean(volumeRow) && Boolean(loopRow),
JSON.stringify({ volumeRow, loopRow }),
);
await shot('34-sound-asset');
// 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the
// asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'sn-logic',
name: 'Noisy',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify({
nodes: [
node('sn-start', 'OnStart', 0, 0),
node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }),
],
edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')],
})},
});
p.assets.unshift(asset);
${MAKE_OBJECT}
scene.objects.push(
make('sn-box', 'crate', '', ${LANE}, 'Noisy box'),
make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'),
);
__gm.lookAt(0, ${LANE});`);
await evaluate('__gm.sound.stopAll()');
const before = await stats();
await play();
const played = await waitFor(stats, (s) => s.started > before.started);
check(
'a graph playing a sound reaches the library, at the asset volume scaled by the node’s',
played.started === before.started + 1 &&
played.last?.file === blip.file &&
Math.abs(played.last.volume - 0.2) < 0.001 &&
played.last.loop === false,
JSON.stringify(played),
);
await stop();
// 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive
// the world that started it.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`);
await play();
const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true);
check(
'the asset’s own loop flag reaches the library even though the node never asked for one',
looping.playing >= 1 && looping.last?.loop === true,
JSON.stringify(looping),
);
// Well past the blip's own 0.25 s, so a one-shot would have ended by itself here.
await sleep(700);
const stillLooping = await stats();
await stop();
const afterStop = await stats();
check(
'a loop keeps playing while the run is on and ends with it',
stillLooping.playing >= 1 && afterStop.playing === 0,
JSON.stringify({ stillLooping, afterStop }),
);
// 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three
// and not a checkbox: music has to be playable as a one-shot.
const setNodeLoop = (value) =>
mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`);
await setNodeLoop('false');
const beforeOnce = await stats();
await play();
const once = await waitFor(stats, (s) => s.started > beforeOnce.started);
check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once));
await stop();
await setNodeLoop('');
// 34g. `Stop sound` silences it from inside the graph, without ending the run.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))});
graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))});
graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('s');
const silenced = await waitFor(stats, (s) => s.playing === 0);
check(
'Stop sound silences a looping sound while the run carries on',
silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(silenced),
);
await stop();
// 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it,
// without any `Stop sound` in the graph. `detached` is the one way out, for the death
// sound of an object that destroys itself.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(
${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))},
${JSON.stringify(node('sn-owner', 'Owner', 260, 620))},
${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))},
);
graph.edges.push(
${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))},
${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))},
);`);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
const destroyed = await waitFor(stats, (s) => s.playing === 0);
check(
'destroying the object silences the looping sound its behavior was playing',
destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(destroyed),
);
await stop();
const setDetached = (value) =>
mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`);
await setDetached(true);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
// Long enough that the reaping frame has been and gone – a voice still here now is here on purpose.
await sleep(600);
const detached = await stats();
check(
'a detached voice is nobody’s and plays on after the object that started it is gone',
detached.playing >= 1,
JSON.stringify(detached),
);
await stop();
check(
'ending the run silences a detached voice too',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
await setDetached(false);
// 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph
// from compiling; built into Input → Pitch → Gain → Output, the header plays the sound
// through it, and a `Play sound` naming it sends the game's voice the same way. The pitch
// comes from a parameter `depth` of the Input: the header has a field for it, and a
// `Sound FX parameters` node hands the game's value in.
await showTab('scene');
await clickText('+');
await pickAssetType(t, 'Sound FX');
const fxCreated = await json(`{
tab: __gm.tab(),
current: __gm.soundfx.current(),
info: __gm.soundfx.info(),
preview: __gm.soundfx.preview(),
assets: __gm.state.project.assets.filter((a) => a.type === 'soundfx').map((a) => a.id),
}`);
check(
'the + menu creates a sound FX asset and opens its tab, with no Output to compile and no Play',
fxCreated.tab === 'soundfx' &&
fxCreated.assets.length === 1 &&
fxCreated.current === fxCreated.assets[0] &&
fxCreated.info === 'No Output node' &&
fxCreated.preview.sound === soundId &&
fxCreated.preview.play === false,
JSON.stringify(fxCreated),
);
const fxId = fxCreated.assets[0];
const fxCanvas = await json('__gm.soundfx.canvasRect()');
await click(fxCanvas.x + 80, fxCanvas.y + 60, 'right');
const fxPalette = await json('__gm.overlayTexts()');
check(
'its palette offers the sound effects and no behavior node',
['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix', 'Delay'].every((label) =>
fxPalette.includes(label),
) && !fxPalette.includes('Branch'),
JSON.stringify(fxPalette.slice(0, 12)),
);
await press('Escape');
await mutate(`
const fx = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)});
fx.name = 'Low';
fx.graph = ${JSON.stringify({
nodes: [
node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]),
node('fx-pitch', 'SoundFxPitch', 220, 0),
node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }),
node('fx-out', 'SoundFxOutput', 660, 0),
],
edges: [
edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'),
edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'),
edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'),
edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'),
],
})};`);
const fxBuilt = await json(
'{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }',
);
check(
'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default',
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12,
JSON.stringify(fxBuilt),
);
await evaluate('__gm.sound.stopAll()');
const beforeFx = await stats();
const fxPlayAt = await json('__gm.soundfx.playAt()');
await click(fxPlayAt.x, fxPlayAt.y);
const heardFx = await waitFor(stats, (s) => s.started > beforeFx.started);
const blipSeconds = await json(`__gm.sound.duration(${JSON.stringify(blip.file)})`);
check(
'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage',
heardFx.last?.file === blip.file &&
heardFx.last.pitches.join() === '-12' &&
heardFx.last.duration === blipSeconds &&
heardFx.last.stages === 1,
JSON.stringify({ ...heardFx, blipSeconds }),
);
await evaluate('__gm.sound.stopAll()');
await evaluate('__gm.soundfx.setParam("depth", 5)');
const beforeParam = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
check(
'the header plays with the value its depth field is set to',
heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1,
JSON.stringify(heardParam),
);
await evaluate('__gm.sound.stopAll()');
// #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))},
${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))},
${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))},
);`);
const mixInfo = await json('__gm.soundfx.info()');
const beforeMix = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started);
check(
'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length',
mixInfo === '3 effects' &&
heardMix.last?.pitches.join() === '5,0' &&
heardMix.last.stages === 1 &&
heardMix.last.duration === blipSeconds,
JSON.stringify({ mixInfo, ...heardMix }),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix');
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come
// 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full
// (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes
// then. Asked to let ring, only the sound stops and the voice stays; a stop after that
// cuts it.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-delay', 'SoundFxDelay', 550, 0, { time: 0.3, feedback: 0.5, mix: 0.5, pingPong: true }),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e8', 'fx-gain', 'out', 'fx-delay', 'in'))},
${JSON.stringify(edge('fx-e9', 'fx-delay', 'out', 'fx-out', 'in'))},
);`);
const delayInfo = await json('__gm.soundfx.info()');
await evaluate('__gm.sound.stopAll()');
const beforeEcho = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const echoStarted = await waitFor(stats, (s) => s.started > beforeEcho.started);
// Timed on the audio clock: the page's can fall behind it (task 523 saw a 2.95 s tail end 1 s in).
const audioNow = () => json('({ playing: __gm.sound.stats().playing, clock: __gm.sound.clock() })');
const startedAt = (await audioNow()).clock;
const echoLevels = await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`);
const ringing = await waitFor(audioNow, (s) => s.clock - startedAt >= 1);
const quiet = await waitFor(audioNow, (s) => s.playing === 0, 40);
const rangFor = quiet.clock - startedAt;
const echoKnobs = echoLevels?.[0]?.slice(2) ?? [];
check(
'a Delay keeps the voice after its sound has ended, until its echoes have faded',
delayInfo === '3 effects' &&
echoStarted.last?.stages === 2 &&
echoKnobs.length === 5 &&
Math.abs(echoKnobs[0] - 0.3) < 1e-6 &&
Math.abs(echoKnobs[1] - 0.5) < 1e-6 &&
Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 &&
Math.abs(echoKnobs[3] - 1) < 1e-6 &&
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
ringing.playing === 1 &&
ringing.clock - startedAt < 2.4 &&
quiet.playing === 0 &&
rangFor > 2.4 &&
rangFor < 4.5,
JSON.stringify({ delayInfo, last: echoStarted.last, echoLevels, ringing, startedAt, rangFor }),
);
await click(fxPlayAt.x, fxPlayAt.y);
await waitFor(stats, (s) => s.playing === 1);
const beforeLetRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)}, true)`);
await sleep(500);
const letRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)})`);
const cut = await stats();
check(
'Let ring stops the sound and keeps the voice for its echoes; a stop after that cuts it',
letRing.playing === 1 &&
letRing.stopped === beforeLetRing.stopped + 1 &&
cut.playing === 0 &&
cut.stopped === beforeLetRing.stopped + 2,
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
);
// #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
const bypassed = await json(
"({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })",
);
const beforeBypass = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
await evaluate('__gm.sound.stopAll()');
check(
'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain',
bypassed.alpha < 0.5 &&
bypassed.gain === 1 &&
bypassed.settings?.includes('Bypass: true') &&
heardBypass.last?.stages === 1,
JSON.stringify({ bypassed, last: heardBypass.last }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
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, wet 0.25 – the mix 0.5 of before #354) comes back left at 0.3 s at half its level, right at 0.6 s at
// half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
const echo = await evaluate(`
(async () => {
const RATE = 48000;
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong });
const render = async (voices, seconds, stage = echoOf(true)) => {
const context = new OfflineAudioContext(2, RATE * seconds, RATE);
const burst = context.createBuffer(1, RATE * 0.01, RATE);
burst.getChannelData(0).fill(0.5);
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = burst;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = await context.startRendering();
return { left: out.getChannelData(0), right: out.getChannelData(1), ms: performance.now() - started };
};
// The middle of the burst: its edges ring a little through the damping.
const peak = (data, at) => {
let sum = 0;
const from = Math.round((at + 0.003) * RATE);
const to = Math.round((at + 0.007) * RATE);
for (let i = from; i < to; i++) sum += Math.abs(data[i]);
return Math.round((sum / (to - from)) * 1000) / 1000;
};
// Samples from where each echo is due to where it is heard, in both kinds of echo.
const late = ({ left, right }) => {
const found = [];
for (let k = 1; k <= 4; k++) {
const from = Math.round((k * 0.3 - 0.05) * RATE);
let at = from;
while (at < from + RATE * 0.1 && Math.abs(left[at]) <= 0.002 && Math.abs(right[at]) <= 0.002) at++;
found.push(at - Math.round(k * 0.3 * RATE));
}
return found;
};
const one = await render(1, 1.3);
const plain = await render(1, 1.3, echoOf(false));
const load = await render(64, 2);
const lateBy = late(one);
const at = (k) => k * 0.3 + lateBy[k - 1] / RATE;
return {
late: lateBy,
latePlain: late(plain),
dry: [peak(one.left, 0), peak(one.right, 0)],
first: [peak(one.left, at(1)), peak(one.right, at(1))],
second: [peak(one.left, at(2)), peak(one.right, at(2))],
third: [peak(one.left, at(3)), peak(one.right, at(3))],
between: [peak(one.left, 0.45), peak(one.right, 0.45)],
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
const near = (value, to) => Math.abs(value - to) < 0.02;
check(
'the player’s ping-pong echo comes back left, then right, then left, each round half as loud',
near(echo.dry[0], 0.5) &&
near(echo.dry[1], 0.5) &&
near(echo.first[0], 0.25) &&
echo.first[1] < 0.005 &&
echo.second[0] < 0.005 &&
near(echo.second[1], 0.125) &&
near(echo.third[0], 0.0625) &&
echo.third[1] < 0.005 &&
echo.between.every((v) => v < 0.005) &&
// Chrome takes a render quantum more for every round of a feedback loop.
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
JSON.stringify(echo),
);
// #322: the Bitcrusher on the worklet, rendered offline. A 440 Hz sine at 0.8 through 2 bits at
// 4 kHz holds every value for 12 samples and leaves only steps of 0.5. 16 bits at 48 kHz
// leave the sound as it is, a mix of 0.5 is half of each. Then 64 stereo voices for 2 s,
// timed, for how many this machine crushes at once.
const crushed = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, stage) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const tone = context.createBuffer(channels, RATE * seconds, RATE);
for (let c = 0; c < channels; c++) {
const data = tone.getChannelData(c);
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.8;
}
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = tone;
const nodes = __gm.sound.stageNodes(context, { kind: 'crush', ...stage });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, tone: tone.getChannelData(0), ms: performance.now() - started };
};
const crush = await render(1, 0.5, 1, { bits: 2, rate: 4000, mix: 1 });
let offStep = 0;
let unheld = 0;
for (let i = 0; i < crush.out.length; i++) {
offStep = Math.max(offStep, Math.abs(crush.out[i] * 2 - Math.round(crush.out[i] * 2)));
if (crush.out[i] !== crush.out[i - (i % 12)]) unheld++;
}
const levels = [...new Set(crush.out)].sort((a, b) => a - b);
const clean = await render(1, 0.5, 1, { bits: 16, rate: 48000, mix: 1 });
const half = await render(1, 0.5, 1, { bits: 1, rate: 48000, mix: 0.5 });
let cleanOff = 0;
let halfOff = 0;
for (let i = 0; i < clean.out.length; i++) {
cleanOff = Math.max(cleanOff, Math.abs(clean.out[i] - clean.tone[i]));
halfOff = Math.max(halfOff, Math.abs(half.out[i] - (half.tone[i] + Math.round(half.tone[i])) / 2));
}
const load = await render(64, 2, 2, { bits: 6, rate: 11025, mix: 0.8 });
return {
levels,
offStep,
unheld,
cleanOff,
halfOff,
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
check(
'the Bitcrusher holds the sound at its rate and rounds it to its bits, and 16 bits at 48 kHz leave it as it is',
JSON.stringify(crushed.levels) === JSON.stringify([-1, -0.5, 0, 0.5, 1]) &&
crushed.offStep < 1e-6 &&
crushed.unheld === 0 &&
crushed.cleanOff < 2 ** -15 &&
crushed.halfOff < 1e-6,
JSON.stringify(crushed),
);
// #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser
// at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second,
// the notch passes it and is two octaves off between, where cos(28°) of it is left. A
// Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples
// at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples
// later. Then 64 stereo voices of each for 2 s, timed.
const swirled = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, stage, fill, at = 0) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(channels, RATE * seconds, RATE);
for (let c = 0; c < channels; c++) fill(sound.getChannelData(c));
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, ms: performance.now() - started };
};
const sine = (data) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8;
};
const rms = (out, from, to) => {
let sum = 0;
for (let i = from; i < to; i++) sum += out[i] ** 2;
return Math.sqrt(sum / (to - from));
};
const phaser = (stages, rate, depth) =>
({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 });
const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out;
const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out;
const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out;
const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200));
const flanger = (offset, sweep) =>
({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 });
const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; };
const heard = (out, from) => {
const found = [];
for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]);
return found;
};
const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE));
const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE));
const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; };
const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise);
const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise);
return {
notch: +rms(notch, 4800, notch.length).toFixed(4),
peak: +rms(peak, 4800, peak.length).toFixed(4),
sweptLow: +Math.min(...windows).toFixed(3),
sweptHigh: +Math.max(...windows).toFixed(3),
still,
sweeping,
phaserVoices: Math.round((64 * 2000) / phaserLoad.ms),
flangerVoices: Math.round((64 * 2000) / flangerLoad.ms),
};
})()`);
check(
'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice',
swirled.notch < 0.01 &&
Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 &&
swirled.sweptLow < 0.1 &&
swirled.sweptHigh > 0.46 &&
swirled.sweptHigh < 0.51 &&
JSON.stringify(swirled.still) ===
JSON.stringify([
[0, 0.5],
[48, 0.5],
]) &&
swirled.sweeping[0]?.[1] === 0.5 &&
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
JSON.stringify(swirled),
);
// #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump
// falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at
// 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out.
const filtered = await evaluate(`
(async () => {
const RATE = 48000;
const level = async (hz, stage) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
const tone = context.createOscillator();
tone.frequency.value = hz;
const half = context.createGain();
half.gain.value = 0.5;
const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage });
tone.connect(half).connect(nodes.input);
nodes.output.connect(context.destination);
tone.start();
const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4);
let sum = 0;
for (const v of out) sum += v * v;
return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2);
};
return {
low12: await level(2000, { filter: 'lowpass', q: -3.0103 }),
low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }),
peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }),
notch: await level(1000, { filter: 'notch', q: 1 }),
};
})()`);
check(
'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out',
Math.abs(filtered.low12 + 12.3) < 0.2 &&
Math.abs(filtered.low48 + 49.2) < 0.8 &&
Math.abs(filtered.peak - 6) < 0.1 &&
filtered.notch < -40,
JSON.stringify(filtered),
);
// #319: Pan rendered offline. A mono sine at 0.5 all to the left leaves the right silent, in the
// middle each side gets 0.707 of it. A stereo sound panned half right keeps its right side
// and moves part of its left into it, as Web Audio pans stereo: left 0.707, right 1 + 0.707.
const panned = await evaluate(`
(async () => {
const RATE = 48000;
const peaks = async (pan, channels) => {
const context = new OfflineAudioContext(2, RATE / 10, RATE);
const sound = context.createBuffer(channels, RATE / 10, RATE);
for (let c = 0; c < channels; c++) {
const data = sound.getChannelData(c);
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.5;
}
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'pan', pan });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
const out = await context.startRendering();
return [0, 1].map((c) => +Math.max(...out.getChannelData(c).map(Math.abs)).toFixed(3));
};
return { left: await peaks(-1, 1), middle: await peaks(0, 1), balance: await peaks(0.5, 2) };
})()`);
check(
'Pan moves a mono sound between the sides, and a stereo one towards the side it goes to',
JSON.stringify(panned) ===
JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }),
JSON.stringify(panned),
);
// #326: Compressor and Limiter rendered offline. An impulse comes out of the Compressor 288
// samples late (the spec's 6 ms) and out of the Limiter 240 (5 ms). A 1 kHz sine at full
// scale through a hard-knee Compressor at -12 dB, 4:1 with full auto-gain comes back near
// full scale; with no auto-gain and no makeup it is 9 dB down. A sine at 2 through a
// Limiter at -6 dB never goes past it, with auto-gain 1 it sits at the ceiling of -1 dB.
const dynamics = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(1, RATE / 2, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const impulse = (data) => { data[480] = 0.01; };
const sine = (amplitude) => (data) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * amplitude;
};
const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; };
const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2);
const compress = (gain) => ({ kind: 'compress', threshold: -12, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain });
const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain });
const limited = await render(limit(0), sine(2));
return {
compressorLate: loudest(await render(compress(1), impulse)) - 480,
limiterLate: loudest(await render(limit(0), impulse)) - 480,
autoGain: db(await render(compress(10 ** (3.6 / 20)), sine(1))),
noMakeup: db(await render(compress(10 ** (-5.4 / 20)), sine(1))),
limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3),
ceiling: db(await render(limit(1), sine(2))),
};
})()`);
check(
'a Compressor is 6 ms late and auto-gain brings full scale back, a Limiter is 5 ms late and never passes its limit',
dynamics.compressorLate === 288 &&
dynamics.limiterLate === 240 &&
Math.abs(dynamics.autoGain) < 0.5 &&
Math.abs(dynamics.noMakeup + 9) < 0.5 &&
dynamics.limitedPeak <= -6 + 1e-3 &&
dynamics.ceiling <= -1 + 1e-3 &&
dynamics.ceiling > -1.2,
JSON.stringify(dynamics),
);
// #323: Saturation rendered offline. An impulse through it comes out 192 samples late at 48 and
// at 44.1 kHz, the oversampling's delay. Half bent and half dry at 1125 Hz – where 192
// samples off would cancel – the two meet in step and the sine stays as loud. A sine at
// -12 dBFS driven 24 dB into tanh comes back as loud with full auto-gain.
const saturated = await evaluate(`
(async () => {
const render = async (rate, stage, fill) => {
const context = new OfflineAudioContext(1, rate / 2, rate);
const sound = context.createBuffer(1, rate / 2, rate);
fill(sound.getChannelData(0), rate);
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'saturate', curve: 'hard', drive: 0, tone: 20000, level: 1, mix: 1, ...stage });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const impulse = (data) => { data[480] = 0.5; };
const sine = (hz, amplitude) => (data, rate) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * hz * i) / rate) * amplitude;
};
const loudest = (out) => out.reduce((at, v, i) => (Math.abs(v) > Math.abs(out[at]) ? i : at), 0);
const db = (out, amplitude) => {
let sum = 0;
const from = out.length / 2;
for (let i = from; i < out.length; i++) sum += out[i] ** 2;
return +(20 * Math.log10(Math.sqrt(sum / (out.length - from)) / (amplitude * Math.SQRT1_2))).toFixed(2);
};
return {
late48: loudest(await render(48000, {}, impulse)) - 480,
late44: loudest(await render(44100, {}, impulse)) - 480,
halfDry: db(await render(48000, { mix: 0.5 }, sine(1125, 0.5)), 0.5),
autoGain: db(await render(48000, { curve: 'soft', drive: 24, level: 10 ** (__gm.sound.saturationAutoGain('soft', 24) / 20) }, sine(1000, 0.25)), 0.25),
};
})()`);
check(
'a Saturation is 192 samples late at either rate, its dry half meets the bent one in step, auto-gain keeps it as loud',
saturated.late48 === 192 &&
saturated.late44 === 192 &&
Math.abs(saturated.halfDry) < 0.1 &&
Math.abs(saturated.autoGain) < 0.5,
JSON.stringify(saturated),
);
// #320: Tremolo and Vibrato on the worklet, rendered offline. A Tremolo square at 10 Hz, depth 1,
// on a steady 0.5 for a voice starting at 0.1 s: silence before it, then 0.5 for 50 ms and
// 0 for 50 ms. A Vibrato square at 5 Hz, one semitone deep, on a 1 kHz sine: the first
// half of a period at 1059.5 Hz, the second at 940.5 Hz.
const swung = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill, at = 0) => {
const context = new OfflineAudioContext(1, RATE, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(1, RATE, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE); };
const levels = (out, from, to) => [...new Set(out.subarray(from, to))].map((v) => +v.toFixed(3));
const tremolo = await render({ kind: 'tremolo', shape: 'square', rate: 10, depth: 1 }, steady, 0.1);
const hz = (out, from, to) => {
const at = [];
for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++)
if (out[i - 1] < 0 && out[i] >= 0) at.push(i - out[i] / (out[i] - out[i - 1]));
return +(((at.length - 1) * RATE) / (at.at(-1) - at[0])).toFixed(1);
};
const vibrato = await render({ kind: 'vibrato', shape: 'square', rate: 5, depth: 1 }, sine);
return {
before: levels(tremolo, 0, 4800),
on: levels(tremolo, 4810, 7190),
off: levels(tremolo, 7210, 9590),
up: hz(vibrato, 0.41, 0.49),
down: hz(vibrato, 0.51, 0.59),
};
})()`);
check(
'a Tremolo starts its swing with its voice, a Vibrato jumps a semitone up and as far down',
JSON.stringify([swung.before, swung.on, swung.off]) === JSON.stringify([[0], [0.5], [0]]) &&
Math.abs(swung.up - 1059.5) < 1 &&
Math.abs(swung.down - 940.5) < 1,
JSON.stringify(swung),
);
// #328: Reverb rendered offline. An impulse at 0.05 s through a Custom reverb of 1 s with a
// pre-delay of 50 ms: nothing of it before 0.1 s, then it falls by 60 dB a second, 36 dB
// from 0.2-0.3 s to 0.8-0.9 s after it. At mix 0.5 the impulse itself is there at half.
// With a sound as its impulse response – one sample at 0.2 s – the impulse comes back
// 0.2 s and the pre-delay later, and nowhere else.
const reverbed = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, impulse) => {
const context = new OfflineAudioContext(1, RATE * 1.5, RATE);
const sound = context.createBuffer(1, RATE * 1.5, RATE);
sound.getChannelData(0)[RATE * 0.05] = 0.5;
let ir;
if (impulse) {
ir = context.createBuffer(2, RATE / 2, RATE);
for (let c = 0; c < 2; c++) ir.getChannelData(c)[RATE * impulse] = 1;
}
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'reverb', decay: 1, damping: 0, predelay: 0.05, mix: 1, ...stage }, 0, ir);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const db = (out, from, to) => {
let sum = 0;
for (let i = from * RATE; i < to * RATE; i++) sum += out[i] ** 2;
return 10 * Math.log10(sum / ((to - from) * RATE));
};
const peak = (out, from, to) => Math.max(...out.subarray(from * RATE, to * RATE).map(Math.abs));
const wet = await render({});
const half = await render({ mix: 0.5 });
const sounded = await render({ impulse: 'room.wav' }, 0.2);
const heard = [];
sounded.forEach((v, i) => { if (Math.abs(v) > 1e-6) heard.push(i); });
return {
before: peak(wet, 0, 0.0999),
fall: +(db(wet, 0.25, 0.35) - db(wet, 0.85, 0.95)).toFixed(1),
dry: +half[RATE * 0.05].toFixed(4),
sound: [heard[0], heard.at(-1)],
};
})()`);
check(
'a Reverb waits its pre-delay, falls 60 dB in its decay, and takes a sound as its impulse response',
reverbed.before === 0 &&
Math.abs(reverbed.fall - 36) < 3 &&
reverbed.dry === 0.25 &&
JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]),
JSON.stringify(reverbed),
);
// #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of
// 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75
// the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended
// half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it
// came in is held back to meet it.
const wetted = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
const sound = context.createBuffer(1, RATE / 2, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; };
const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3);
const delay = async (mix) => {
const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady);
return [at(out, 0.05), at(out, 0.2)];
};
const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 };
const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine);
let sum = 0;
for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2;
return {
gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1),
quarter: await delay(0.25),
threeQuarters: await delay(0.75),
echoOnly: await delay(1),
compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2),
};
})()`);
check(
'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step',
wetted.gain === 0.375 &&
JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) ===
JSON.stringify([
[0.5, 0.75],
[0.25, 0.75],
[0, 0.5],
]) &&
Math.abs(wetted.compressed) < 0.1,
JSON.stringify(wetted),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
// 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed.
const chipped = await evaluate(`
(async () => {
const RATE = 48000;
const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0,
vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 };
const render = async (voices, seconds, params, options) => {
const context = new OfflineAudioContext(1, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const said = [];
for (let v = 0; v < voices; v++) {
const node = new AudioWorkletNode(context, 'chip-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: params,
processorOptions: { ...shape, ...options },
});
node.port.onmessage = (event) => said.push(event.data);
node.connect(context.destination);
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
const ms = performance.now() - started;
await new Promise((resolve) => setTimeout(resolve, 100));
return { out, ms, said };
};
const cycles = (out, from, to) => {
let n = 0;
for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++;
return n;
};
const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 });
const heard = tone.out.subarray(0.25 * RATE);
const firstHeard = tone.out.findIndex((v) => v !== 0);
const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 });
const lastHeard = slid.out.findLastIndex((v) => v !== 0);
const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 });
return {
firstHeard,
periods: cycles(tone.out, 0.25, 1),
up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000,
peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0),
lastHeard: lastHeard / RATE,
said: slid.said,
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
check(
'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency',
chipped.firstHeard === 0.25 * 48000 &&
Math.abs(chipped.periods - 330) <= 1 &&
Math.abs(chipped.up - 0.25) < 0.01 &&
Math.abs(chipped.peak - 0.5) < 0.01 &&
Math.abs(chipped.lastHeard - 1) < 0.01 &&
chipped.said.join() === 'ended',
JSON.stringify(chipped),
);
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
const atCenter = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (mix, glide) => {
const context = new OfflineAudioContext(2, RATE, RATE);
const merger = context.createChannelMerger(2);
merger.connect(context.destination);
const shareA = context.createGain();
const shareB = context.createGain();
shareA.gain.value = 0;
shareB.gain.value = 0;
const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB);
for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) {
const steady = context.createConstantSource();
steady.offset.value = level;
steady.connect(share).connect(merger, 0, side);
steady.start();
}
if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1);
driver.start();
const out = await context.startRendering();
const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2];
return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5);
};
const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000);
const fixed = {};
for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false));
return { fixed, glide: (await render(0, true)).map(round) };
})()`);
const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b);
check(
'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides',
pairNear(atCenter.fixed[0], 1, 0) &&
pairNear(atCenter.fixed[0.25], 1, 0.5) &&
pairNear(atCenter.fixed[0.5], 1, 1) &&
pairNear(atCenter.fixed[0.75], 0.5, 1) &&
pairNear(atCenter.fixed[1], 0, 1) &&
pairNear(atCenter.glide[0], 1, 0.5) &&
pairNear(atCenter.glide[1], 1, 1) &&
pairNear(atCenter.glide[2], 0.5, 1),
JSON.stringify(atCenter),
);
await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
logic.variables = [];
const graph = logic.graph;
graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id));
graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp');
graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp');
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' });
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);
// 34g-5. An instrument (#331): "+ → Instrument" opens its tab on Notes → Sample → Output, which
// plays nothing until the Sample has a sound. With the blip, looped, every key of C4–B4
// sounds; a key held by the pointer plays until it is let go, the computer keyboard plays
// as a tracker does, and a game's `Play note` releases its note after its duration.
await showTab('scene');
await clickText('+');
await pickAssetType(t, 'Instrument');
const insCreated = await json(`{
tab: __gm.tab(),
current: __gm.instrument.current(),
info: __gm.instrument.info(),
keys: __gm.instrument.keys(),
assets: __gm.state.project.assets.filter((a) => a.type === 'instrument')
.map((a) => ({ id: a.id, nodes: a.graph.nodes.map((n) => n.type) })),
}`);
check(
'the + menu creates an instrument, Notes → Sample → Output, and opens its tab; no key sounds yet',
insCreated.tab === 'instrument' &&
insCreated.assets.length === 1 &&
insCreated.current === insCreated.assets[0].id &&
insCreated.assets[0].nodes.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput' &&
insCreated.info === 'No sound picked' &&
insCreated.keys.sounding.length === 0,
JSON.stringify(insCreated),
);
const insId = insCreated.assets[0]?.id;
// Bottom left: the fitted graph is one row of three nodes, its links among them.
const insCanvas = await json('__gm.instrument.canvasRect()');
await click(insCanvas.x + 30, insCanvas.y + insCanvas.h - 30, 'right');
const insPalette = await json('__gm.overlayTexts()');
check(
'its palette offers its own nodes and the effects, but no Input and no behavior node',
['Notes', 'Sample', 'Parameters', 'Sound FX', 'Envelope', 'Gain', 'Filter', 'Delay'].every((label) =>
insPalette.includes(label),
) &&
!insPalette.includes('Input') &&
!insPalette.includes('Branch'),
JSON.stringify(insPalette.slice(0, 14)),
);
await press('Escape');
await mutate(`
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, {
sound: ${JSON.stringify(soundId)},
play: 'loop',
});`);
const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }');
check(
'with a sound picked the twelve keys from C4 to B4 sound, the others stay grey',
insReady.info === '12 notes' &&
insReady.keys.sounding.join() === [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71].join(),
JSON.stringify(insReady),
);
await shot('34-instrument');
await evaluate('__gm.sound.stopAll()');
const beforeKey = await stats();
const e4 = await json('__gm.instrument.keyAt(64)');
await t.mouse('mouseMoved', e4.x, e4.y);
await t.mouse('mousePressed', e4.x, e4.y);
const heldKey = await waitFor(stats, (s) => s.started > beforeKey.started);
// Longer than the blip: a looped note that ended with its sound would be gone by now.
await sleep(600);
const stillHeld = await json('{ stats: __gm.sound.stats(), keys: __gm.instrument.keys() }');
check(
'holding E4 plays the blip four semitones up, looped, as one layer of the instrument, and lights the key',
heldKey.last?.file === `instrument:${insId}` &&
heldKey.last.pitches.join() === '4' &&
heldKey.last.layers === 1 &&
stillHeld.stats.playing === 1 &&
stillHeld.keys.held.join() === '64',
JSON.stringify({ heldKey, stillHeld }),
);
await t.mouse('mouseReleased', e4.x, e4.y);
const letGo = await waitFor(stats, (s) => s.playing === 0);
check(
'letting go of the key ends the note: its sample fades out and the voice goes',
letGo.playing === 0 && (await json('__gm.instrument.keys()')).held.length === 0,
JSON.stringify(letGo),
);
// "Loop, then release": held, it loops 0.05 to 0.1 s; let go, it plays the 0.15 s after the loop.
const setPlay = (fields) =>
mutate(`
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, ${JSON.stringify(fields)});`);
await setPlay({ play: 'release', loopStart: 0.05, loopEnd: 0.1 });
const beforeRelease = await stats();
await t.mouse('mousePressed', e4.x, e4.y);
await waitFor(stats, (s) => s.started > beforeRelease.started);
await sleep(600);
const heldLoop = await stats();
await t.mouse('mouseReleased', e4.x, e4.y);
const leftAt = await audioNow();
const ringingKeys = await json('__gm.instrument.keys()');
const playedOut = await waitFor(audioNow, (s) => s.playing === 0);
const afterKeys = await waitFor(
() => json('__gm.instrument.keys()'),
(keys) => keys.ringing.length === 0,
);
// 0.15 s after the loop, up to two rounds more (the one it is in, the one the worklet read into)
// and the 150 ms waitFor polls at: a fade would be gone in 0.01 s.
const outFor = playedOut.clock - leftAt.clock;
check(
'"Loop, then release" loops while held, and let go plays on past the loop with its key half lit',
heldLoop.playing === 1 &&
ringingKeys.held.length === 0 &&
ringingKeys.ringing.join() === '64' &&
outFor > 0.12 &&
outFor < 0.5 &&
afterKeys.ringing.length === 0,
JSON.stringify({ heldLoop: heldLoop.playing, ringingKeys, outFor, afterKeys }),
);
await setPlay({ play: 'loop', loopStart: 0, loopEnd: 0 });
// The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4.
const beforeZ = await stats();
await press('z');
await sleep(200);
const lowZ = await stats();
await press('ArrowRight');
await t.keyDown('z');
const typed = await waitFor(stats, (s) => s.started > lowZ.started);
const typedKeys = await json('__gm.instrument.keys()');
await t.keyUp('z');
const typedOff = await waitFor(stats, (s) => s.playing === 0);
check(
'the computer keyboard plays as a tracker: Z is C3 and silent here, one octave up it is C4 until let go',
lowZ.started === beforeZ.started &&
typedKeys.octave === 4 &&
typedKeys.held.join() === '60' &&
typed.last?.pitches.join() === '0' &&
typedOff.playing === 0,
JSON.stringify({ beforeZ: beforeZ.started, lowZ: lowZ.started, typed, typedKeys, typedOff }),
);
await press('ArrowLeft');
// Twice the eleven keys off the root, all held at once: each plays through the pitch worklet, and
// the ten oldest are cut, so no more than twelve phase vocoders run and the audio thread keeps up.
await evaluate('__gm.sound.stopAll()');
const crowd = await json(`(() => {
const voices = [...Array(22)].map((_, i) => __gm.instrument.play(61 + (i % 11)));
return { started: voices.filter((v) => v > 0).length, playing: voices.map((v) => __gm.sound.playing(v)), stats: __gm.sound.stats() };
})()`);
check(
'held at once, 22 notes off the root keep only the 12 newest on the pitch worklet',
crowd.started === 22 &&
crowd.playing.join() === [...Array(22)].map((_, i) => i >= 10).join() &&
crowd.stats.vocoders === 12 &&
crowd.stats.playing === 12,
JSON.stringify({ started: crowd.started, playing: crowd.playing, vocoders: crowd.stats.vocoders }),
);
// Closing the tab ends what it plays: E4, looped and held by no key, falls silent with it.
await evaluate('__gm.sound.stopAll()');
await json('__gm.instrument.play(64)');
const heldBeforeClose = await stats();
await evaluate(`__gm.closeDocument('asset:${insId}')`);
await sleep(300);
const afterClose = await stats();
await evaluate(`__gm.openDocument('asset:${insId}')`);
check(
'closing the Instrument tab ends a note it still plays',
heldBeforeClose.playing === 1 && afterClose.playing === 0,
JSON.stringify({ held: heldBeforeClose.playing, closed: afterClose.playing }),
);
// 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and
// Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change
// only plays with Audition on. Three undos bring the blip back.
const insGraph = () =>
json(`(() => {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null;
return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') };
})()`);
const clickHook = async (hook) => {
const at = await json(`__gm.instrument.${hook}()`);
await click(at.x, at.y);
};
await evaluate('__gm.sound.stopAll()');
const beforePreset = await stats();
await clickHook('presetAt');
const presetMenu = await json('__gm.overlayTexts()');
await clickText('Laser');
await sleep(400);
const preset = await stats();
const laser = await insGraph();
const presetInfo = await json('__gm.instrument.info()');
check(
'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays nothing',
['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) =>
presetMenu.includes(label),
) &&
laser.types.join() ===
'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' &&
laser.chip?.followNote === false &&
laser.chip.slide < 0 &&
laser.envelope?.sustain === 0 &&
preset.started === beforePreset.started &&
presetInfo === '128 notes',
JSON.stringify({ presetMenu, laser, started: [beforePreset.started, preset.started], presetInfo }),
);
await shot('34-instrument-preset');
const beforeRandom = await stats();
await clickHook('randomizeAt');
const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);
const drawn = await insGraph();
await clickHook('mutateAt');
const mutated = await waitFor(stats, (s) => s.started > randomized.started);
const moved = await insGraph();
check(
'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once',
randomized.started > beforeRandom.started &&
drawn.chip?.wave === laser.chip.wave &&
drawn.chip.frequency !== laser.chip.frequency &&
mutated.started > randomized.started &&
JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]),
JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }),
);
const setFrequency = (hz) =>
mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)})
.graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`);
await sleep(300);
const silent = await stats();
await setFrequency(300);
await sleep(500);
const unheard = await stats();
await clickHook('auditionAt');
const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled;
await setFrequency(400);
const auditioned = await waitFor(stats, (s) => s.started > unheard.started);
await clickHook('auditionAt');
check(
'a change to the instrument is heard only with Audition on',
unheard.started === silent.started && auditionOn && auditioned.started > unheard.started,
JSON.stringify({
silent: silent.started,
unheard: unheard.started,
auditionOn,
auditioned: auditioned.started,
}),
);
for (let i = 0; i < 3; i++) await t.clickToolbar('undo');
const restored = await insGraph();
check(
'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again',
restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify(restored.types),
);
// 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and
// plays nothing. One undo takes it back.
await evaluate('__gm.sound.stopAll()');
const beforeLead = await stats();
await clickHook('presetAt');
await clickText('Square Lead');
await sleep(400);
const lead = await stats();
const leadGraph = await insGraph();
const leadInfo = await json('__gm.instrument.info()');
await t.clickToolbar('undo');
const unled = await insGraph();
check(
'Preset → Square Lead plays every note at its own pitch, holds it and plays nothing; one undo takes it back',
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
leadGraph.chip?.followNote === true &&
leadGraph.envelope?.sustain > 0 &&
lead.started === beforeLead.started &&
leadInfo === '128 notes' &&
unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify({
chip: leadGraph.chip,
envelope: leadGraph.envelope,
started: [beforeLead.started, lead.started],
leadInfo,
unled,
}),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify(
node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }),
)});
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`);
// A key still held in the Instrument tab is not part of the game: Play ends it.
const preview = await json('__gm.instrument.play(64)');
const previewHeld = await json(`__gm.sound.playing(${preview})`);
await showTab('scene');
const beforeNote = await stats();
await play();
const previewAfterPlay = await json(`__gm.sound.playing(${preview})`);
check(
'Play ends what the tabs play: a note held in the Instrument tab is gone',
preview > 0 && previewHeld && !previewAfterPlay,
JSON.stringify({ preview, previewHeld, previewAfterPlay }),
);
// The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard
// well past the 0.25 s of the blip, never past its duration, and is gone soon after.
const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })');
const noted = await waitFor(noteNow, (s) => s.stats.started > beforeNote.started);
const heard = await trace(noteNow, (s) => s.stats.playing === 0);
const noteAt = noted.stats.last?.at ?? 0;
const heardAt = heard.filter((s) => s.stats.playing === 1).map((s) => +(s.clock - noteAt).toFixed(3));
const goneAt = +(heard.at(-1).clock - noteAt).toFixed(3);
check(
'a game’s Play note plays G4 – the blip seven semitones up – and lets it go after its duration',
noted.stats.last?.file === `instrument:${insId}` &&
noted.stats.last.pitches.join() === '7' &&
heardAt.some((t) => t > 0.5) &&
heardAt.every((t) => t < 1.6) &&
heard.at(-1).stats.playing === 0 &&
goneAt < 3,
JSON.stringify({ last: noted.stats.last, heardAt, goneAt }),
);
await stop();
// #332: the scheduler, driven by the hook – nothing in the editor plays a sequence yet. A bar of
// quarters at 150 BPM in a loop: every note is planned before its second comes, each 0.4 s
// after the one before, also across the seam into the next round. Where the music stands
// is the audio clock less the output latency, counted from the first note.
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(
'({ position: __gm.music.position(), clock: __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 heardTicks = (((where.clock - where.latency - planned[0].at) * 150 * 480) / 60) % 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) &&
Math.abs(where.position.ticks - heardTicks) < 1 &&
where.position.bar === 1 &&
musicOff.playing === 0 &&
(await json('__gm.music.playing()')) === false,
JSON.stringify({ apart, ahead, where, heardTicks, 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 }),
);
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 onBand = await json('__gm.soundTab.stripAt(0.2)');
await click(onBand.x, onBand.y);
check(
'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound',
undoneClip.clips.length === 0 &&
undoneClip.picked === null &&
redoneClip.map((c) => c.id).join() === clipId &&
pickedBeside === null &&
(await picked()) === clipId,
JSON.stringify({ undoneClip, redoneClip, pickedBeside, 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). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the
// master's volume is a project setting, one undo step.
for (let i = 0; i < 2; i++) {
await clickText('+');
await pickAssetType(t, 'Bus');
await sleep(150);
}
const [busA, busB] = await assetsOfType('bus');
const intoMenu = async () => {
await select({ kind: 'asset', assetId: busA.id });
const at = await json("__gm.inspectorField('Into', true)");
await click(at.x, at.y);
const offered = await json('__gm.overlayTexts()');
await press('Escape');
return offered;
};
const offeredBefore = await intoMenu();
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`);
const offeredAfter = await intoMenu();
check(
'"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it',
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') &&
JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) &&
JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']),
JSON.stringify({ busA, busB, offeredBefore, offeredAfter }),
);
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ${JSON.stringify(busB.id)};`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10);
const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus;
const levelOf = (name) => buses.find((bus) => bus.name === name);
check(
'the Sound tab plays the sound on its bus, into the bus that one names, on into the master',
heardOn === `mix:${busB.id}` &&
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,
JSON.stringify({ heardOn, buses }),
);
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 }),
);
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = '';
p.assets = p.assets.filter((a) => a.type !== 'bus');`);
// 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')),
);
}