Files
feuerwerk/tools/e2e/34-sound.mjs
T
adminandClaude Opus 5.5 28909f0adf
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Successful in 16m6s
Show a Limiter node as a device that holds what it took away (#397)
A Limiter only said what it does in rows of numbers. How much it took
away, and when, could not be seen at all, and a short sound is over
before any needle could show it. Now the node is a device of its own
size, and its screen catches the reduction from the moment a sound
starts and holds it once the sound ends.

- LimiterPanel draws a 720x280 plate of brushed brick. The threshold
  knob is large, ceiling and release are small beside it, auto-gain is
  a lever. A LIMIT lamp lights while it takes something away.
- The screen hangs the reduction down from the top, 0 to 24 dB by the
  square root like the Compressor's meter. The level coming in runs
  along the bottom, orange above the limit. Time runs from the start
  of the sound over 1, 2, 4 or 8 s, and a longer sound rolls.
- ReductionCapture in limiterView holds the capture after 0.4 s of
  silence, without the silence, until the next sound. It writes the
  most taken away and when, and NOW or LASTED on the right.
- The Limiter is tapped now, like the Compressor. The face reads the
  loudest sample since the last frame on both sides.
- SpectrumTaps.levels takes a lateBy. The side in front is read 5 ms
  earlier, so a peak going in is met by what it became.
- limiterReduction leaves the auto-gain out and solves Wet's blend for
  the limited part. Less than 0.2 dB counts as none.
- A capture is also held when the node leaves the picture. Changing a
  knob or the lever leaves it as it is.
- Hook __gm.limiter.panel(id) for the sound FX and instrument graphs.

Decided myself:

- The capture stays held until the next sound, not for a fixed time.
- The screen has no wet knob, as the Compressor's has none.
- The Limiter's two taps cost two gains of 1 per voice. The analysers
  only exist while the node is in the picture.

Measured on stein2: the seeded blip at -4.4 dBFS against -12 dB showed
7.56 dB. Three hits at 0, -8 and -2 dBFS showed 11.9 dB at the first.
In e2e 34 on melektaus-atx, -10.44 dBFS in against -20 dB showed
9.56 dB, the difference to the hundredth.

Tests:
- Unit: limiter-view 13 tests (new), spectrum 6 (1 new), soundfx 67
  with the Limiter's taps.
- Counter-test: without Wet's share in the formula the blend test
  fails.
- e2e 34: ALL PASSED, 138 checks (1 new).
- Gate: CHECK EXIT 0, 290 files, 4445 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:30:59 +02:00

3846 lines
186 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 { execSync } from 'node:child_process';
import { sineWav } from '../lib/audio.mjs';
import { edge, node, port, variable, written } from '../lib/builders.mjs';
import { uploadSound } from '../lib/projects.mjs';
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
import { sampleZoom } from './lib/zoomTransition.mjs';
/**
* Sound assets (#45): uploaded like a typeface – picked from what the server holds through
* "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing
* wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the
* node scales the one and can overrule the other in either direction.
*
* Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness
* is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the
* witness for a repaint that did not happen. No pixel check could see any of this.
*
* The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and
* a 0.75 s drone, told apart by their exact size rather than by their name.
*/
const LANE = LANES.sound;
const BLIP_BYTES = 4044;
const DRONE_BYTES = 12044;
export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
const { select, drag } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
const stats = () => json('__gm.sound.stats()');
/** A voice starts only once its bytes have been decoded, so what it changes is awaited. */
const waitFor = async (read, done, tries = 25) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
// Every reading up to the one `done` takes: a check holds each against its own time on the audio
// clock, however late the first one comes in.
const trace = async (read, done, tries = 40) => {
const seen = [await read()];
for (let i = 0; i < tries && !done(seen.at(-1)); i++) {
await sleep(150);
seen.push(await read());
}
return seen;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 34a. What the server holds, straight from the API: the two files the seed put there.
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
check(
'the server holds the two seeded sounds, named by their content',
Boolean(blip && drone) && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(blip.file),
JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }),
);
if (!blip || !drone) return;
check(
'the listing carries the name each file was uploaded under',
blip.name === 'blip.wav' && drone.name === 'drone.wav',
JSON.stringify({ blip: blip.name, drone: drone.name }),
);
// A file of this run's own that no project uses, for the picker to delete (#344). Random bytes
// behind a WAV header: the store reads the signature only, and no earlier run has the same.
const spare = await evaluate(`(async () => {
const bytes = crypto.getRandomValues(new Uint8Array(48));
bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0);
bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8);
const r = await fetch('/game-maker/api/index.php?r=sounds&name=spare.wav', {
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes,
});
return (await r.json()).file;
})()`);
// 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface.
await clickText('+');
await pickAssetType(t, 'Sound');
await sleep(500);
const picker = await json('__gm.soundPicker()');
check(
'the + menu opens the sound picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0,
JSON.stringify(picker),
);
check(
'a row nobody uses yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav',
JSON.stringify(picker.captions),
);
await shot('34-sound-picker');
// 34b'. A file of one's own that no project uses goes from the picker (#344): the first press of
// Delete only asks, the second deletes it on the server.
const spareRow = await json(`__gm.soundPickerRow(${JSON.stringify(spare)})`);
await click(spareRow.x, spareRow.y);
const marked = await json('__gm.soundPicker()');
check(
'a file of one’s own says whose it is and may be deleted',
marked.selected === spare && marked.detail.includes('owner e2e') && marked.deleteButton.enabled,
JSON.stringify({ selected: marked.selected, spare, detail: marked.detail, button: marked.deleteButton }),
);
await click(marked.deleteButton.x, marked.deleteButton.y);
const armed = await json('__gm.soundPicker()');
await click(armed.deleteButton.x, armed.deleteButton.y);
const emptied = await waitFor(
() => json('__gm.soundPicker()'),
(p) => !p.files.includes(spare),
);
const served = await evaluate(`fetch('/game-maker/data/sounds/${spare}').then((r) => r.status)`);
check(
'the first press asks, the second deletes the file on the server',
armed.deleteButton.shown === 'Delete for good' &&
armed.files.includes(spare) &&
!emptied.files.includes(spare) &&
emptied.files.includes(blip.file) &&
emptied.detail === 'Deleted spare.wav.' &&
served === 404,
JSON.stringify({ armed: armed.deleteButton, left: emptied.files.length, detail: emptied.detail, served }),
);
// 34c. The ▶ at a row's left is a preview: it plays that file without choosing it.
const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`);
await click(playAt.x, playAt.y);
const previewed = await waitFor(stats, (s) => s.started > 0);
const afterPreview = await json('__gm.soundPicker()');
check(
'pressing a row’s ▶ plays that file and picks nothing',
previewed.started === 1 &&
previewed.last?.file === drone.file &&
afterPreview.played === drone.file &&
afterPreview.selected === null,
JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }),
);
const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`);
await click(row.x, row.y);
await clickText('Use sound');
const sounds = await assetsOfType('sound');
check(
'picking a row makes a sound asset of that file, named after the file rather than "Sound"',
sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip',
JSON.stringify(sounds),
);
if (sounds.length !== 1) return;
const soundId = sounds[0].id;
check(
'a fresh sound asset is full volume and one-shot',
sounds[0].props.volume === 1 && sounds[0].props.loop === false,
JSON.stringify(sounds[0].props),
);
// Closing the picker silences its preview; nothing it started may outlive the dialog.
check(
'closing the picker leaves nothing playing',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
// 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is.
await select({ kind: 'asset', assetId: soundId });
const volumeRow = await json("__gm.inspectorField('Volume', true)");
const loopRow = await json("__gm.inspectorField('Loop', true)");
check(
'a sound asset has a Volume row and a Loop row',
Boolean(volumeRow) && Boolean(loopRow),
JSON.stringify({ volumeRow, loopRow }),
);
await shot('34-sound-asset');
// 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the
// asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'sn-logic',
name: 'Noisy',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify({
nodes: [
node('sn-start', 'OnStart', 0, 0),
node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }),
],
edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')],
})},
});
p.assets.unshift(asset);
${MAKE_OBJECT}
scene.objects.push(
make('sn-box', 'crate', '', ${LANE}, 'Noisy box'),
make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'),
);
__gm.lookAt(0, ${LANE});`);
await evaluate('__gm.sound.stopAll()');
const before = await stats();
await play();
const played = await waitFor(stats, (s) => s.started > before.started);
check(
'a graph playing a sound reaches the library, at the asset volume scaled by the node’s',
played.started === before.started + 1 &&
played.last?.file === blip.file &&
Math.abs(played.last.volume - 0.2) < 0.001 &&
played.last.loop === false,
JSON.stringify(played),
);
await stop();
// 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive
// the world that started it.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`);
await play();
const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true);
check(
'the asset’s own loop flag reaches the library even though the node never asked for one',
looping.playing >= 1 && looping.last?.loop === true,
JSON.stringify(looping),
);
// Well past the blip's own 0.25 s, so a one-shot would have ended by itself here.
await sleep(700);
const stillLooping = await stats();
await stop();
const afterStop = await stats();
check(
'a loop keeps playing while the run is on and ends with it',
stillLooping.playing >= 1 && afterStop.playing === 0,
JSON.stringify({ stillLooping, afterStop }),
);
// 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three
// and not a checkbox: music has to be playable as a one-shot.
const setNodeLoop = (value) =>
mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`);
await setNodeLoop('false');
const beforeOnce = await stats();
await play();
const once = await waitFor(stats, (s) => s.started > beforeOnce.started);
check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once));
await stop();
await setNodeLoop('');
// 34g. `Stop sound` silences it from inside the graph, without ending the run.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))});
graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))});
graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('s');
const silenced = await waitFor(stats, (s) => s.playing === 0);
check(
'Stop sound silences a looping sound while the run carries on',
silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(silenced),
);
await stop();
// 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it,
// without any `Stop sound` in the graph. `detached` is the one way out, for the death
// sound of an object that destroys itself.
await mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(
${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))},
${JSON.stringify(node('sn-owner', 'Owner', 260, 620))},
${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))},
);
graph.edges.push(
${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))},
${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))},
);`);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
const destroyed = await waitFor(stats, (s) => s.playing === 0);
check(
'destroying the object silences the looping sound its behavior was playing',
destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true,
JSON.stringify(destroyed),
);
await stop();
const setDetached = (value) =>
mutate(`
const p = __gm.state.project;
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`);
await setDetached(true);
await play();
await waitFor(stats, (s) => s.playing > 0);
await press('d');
// Long enough that the reaping frame has been and gone – a voice still here now is here on purpose.
await sleep(600);
const detached = await stats();
check(
'a detached voice is nobody’s and plays on after the object that started it is gone',
detached.playing >= 1,
JSON.stringify(detached),
);
await stop();
check(
'ending the run silences a detached voice too',
(await stats()).playing === 0,
JSON.stringify(await stats()),
);
await setDetached(false);
// 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph
// from compiling; built into Input → Pitch → Gain → Output, the header plays the sound
// through it, and a `Play sound` naming it sends the game's voice the same way. The pitch
// comes from a parameter `depth` of the Input: the header has a field for it, and a
// `Sound FX parameters` node hands the game's value in.
await showTab('scene');
await clickText('+');
await pickAssetType(t, 'Sound FX');
const fxCreated = await json(`{
tab: __gm.tab(),
current: __gm.soundfx.current(),
info: __gm.soundfx.info(),
preview: __gm.soundfx.preview(),
assets: __gm.state.project.assets.filter((a) => a.type === 'soundfx').map((a) => a.id),
}`);
check(
'the + menu creates a sound FX asset and opens its tab, with no Output to compile and no Play',
fxCreated.tab === 'soundfx' &&
fxCreated.assets.length === 1 &&
fxCreated.current === fxCreated.assets[0] &&
fxCreated.info === 'No Output node' &&
fxCreated.preview.sound === soundId &&
fxCreated.preview.play === false,
JSON.stringify(fxCreated),
);
const fxId = fxCreated.assets[0];
const fxCanvas = await json('__gm.soundfx.canvasRect()');
await click(fxCanvas.x + 80, fxCanvas.y + 60, 'right');
const fxPalette = await json('__gm.overlayTexts()');
check(
'its palette offers the sound effects and no behavior node',
['Input', 'Output', 'Gain', 'Pitch', 'Parametric EQ', 'Mix', 'Delay'].every((label) =>
fxPalette.includes(label),
) && !fxPalette.includes('Branch'),
JSON.stringify(fxPalette.slice(0, 12)),
);
await press('Escape');
await mutate(`
const fx = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)});
fx.name = 'Low';
fx.graph = ${JSON.stringify({
nodes: [
node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]),
node('fx-pitch', 'SoundFxPitch', 220, 0),
node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }),
node('fx-out', 'SoundFxOutput', 660, 0),
],
edges: [
edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'),
edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'),
edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'),
edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'),
],
})};`);
const fxBuilt = await json(
'{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }',
);
check(
'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default',
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12,
JSON.stringify(fxBuilt),
);
await evaluate('__gm.sound.stopAll()');
const beforeFx = await stats();
const fxPlayAt = await json('__gm.soundfx.playAt()');
await click(fxPlayAt.x, fxPlayAt.y);
const heardFx = await waitFor(stats, (s) => s.started > beforeFx.started);
const blipSeconds = await json(`__gm.sound.duration(${JSON.stringify(blip.file)})`);
check(
'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage',
heardFx.last?.file === blip.file &&
heardFx.last.pitches.join() === '-12' &&
heardFx.last.duration === blipSeconds &&
heardFx.last.stages === 1,
JSON.stringify({ ...heardFx, blipSeconds }),
);
await evaluate('__gm.sound.stopAll()');
await evaluate('__gm.soundfx.setParam("depth", 5)');
const beforeParam = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
check(
'the header plays with the value its depth field is set to',
heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1,
JSON.stringify(heardParam),
);
await evaluate('__gm.sound.stopAll()');
// #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))},
${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))},
${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))},
);`);
const mixInfo = await json('__gm.soundfx.info()');
const beforeMix = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started);
check(
'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length',
mixInfo === '3 effects' &&
heardMix.last?.pitches.join() === '5,0' &&
heardMix.last.stages === 1 &&
heardMix.last.duration === blipSeconds,
JSON.stringify({ mixInfo, ...heardMix }),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix');
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come
// 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full
// (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes
// then. Asked to let ring, only the sound stops and the voice stays; a stop after that
// cuts it.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-delay', 'SoundFxDelay', 550, 0, { time: 0.3, feedback: 0.5, mix: 0.5, pingPong: true }),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e8', 'fx-gain', 'out', 'fx-delay', 'in'))},
${JSON.stringify(edge('fx-e9', 'fx-delay', 'out', 'fx-out', 'in'))},
);`);
const delayInfo = await json('__gm.soundfx.info()');
await evaluate('__gm.sound.stopAll()');
const beforeEcho = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const echoStarted = await waitFor(stats, (s) => s.started > beforeEcho.started);
// Timed on the audio clock: the page's can fall behind it (task 523 saw a 2.95 s tail end 1 s in).
const audioNow = () => json('({ playing: __gm.sound.stats().playing, clock: __gm.sound.clock() })');
const startedAt = (await audioNow()).clock;
const echoLevels = await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`);
const ringing = await waitFor(audioNow, (s) => s.clock - startedAt >= 1);
const quiet = await waitFor(audioNow, (s) => s.playing === 0, 40);
const rangFor = quiet.clock - startedAt;
const echoKnobs = echoLevels?.[0]?.slice(2) ?? [];
check(
'a Delay keeps the voice after its sound has ended, until its echoes have faded',
delayInfo === '3 effects' &&
echoStarted.last?.stages === 2 &&
echoKnobs.length === 5 &&
Math.abs(echoKnobs[0] - 0.3) < 1e-6 &&
Math.abs(echoKnobs[1] - 0.5) < 1e-6 &&
Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 &&
Math.abs(echoKnobs[3] - 1) < 1e-6 &&
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
ringing.playing === 1 &&
ringing.clock - startedAt < 2.4 &&
quiet.playing === 0 &&
rangFor > 2.4 &&
rangFor < 4.5,
JSON.stringify({ delayInfo, last: echoStarted.last, echoLevels, ringing, startedAt, rangFor }),
);
await click(fxPlayAt.x, fxPlayAt.y);
await waitFor(stats, (s) => s.playing === 1);
const beforeLetRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)}, true)`);
await sleep(500);
const letRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)})`);
const cut = await stats();
check(
'Let ring stops the sound and keeps the voice for its echoes; a stop after that cuts it',
letRing.playing === 1 &&
letRing.stopped === beforeLetRing.stopped + 1 &&
cut.playing === 0 &&
cut.stopped === beforeLetRing.stopped + 2,
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
);
// #354: the Delay bypassed is drawn faded, and the voice plays as if it were not there. Since
// #393 the Delay is a device, which writes no setting rows, so "Bypass: true" is not read.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
const bypassed = await json(
"({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })",
);
const beforeBypass = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
await evaluate('__gm.sound.stopAll()');
check(
'a bypassed Delay is drawn faded, and the voice passes only the gain',
bypassed.alpha < 0.5 && bypassed.gain === 1 && heardBypass.last?.stages === 1,
JSON.stringify({ bypassed, last: heardBypass.last }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the
// curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the
// voice passes one filter for it.
// #370: the editor is a tab of its own, and goes with its node.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e10', 'fx-gain', 'out', 'fx-eq', 'in'))},
${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))},
);`);
const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')");
await t.doubleClick(eqNode.x, eqNode.y);
const eqShown = await json('__gm.eq.shown()');
const eqDocs = await json('__gm.documents()');
const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)');
await t.doubleClick(eqPeakAt.x, eqPeakAt.y);
const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }');
const bandNear = (band, hz, db) =>
band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5;
check(
'a double-click opens the Parametric EQ in a tab, and one on its curve adds a peak there',
eqShown?.nodeId === 'fx-eq' &&
eqDocs.active === `eq:${fxId}:fx-eq` &&
eqDocs.docs.includes(`asset:${fxId}`) &&
eqAdded.bands.length === 1 &&
eqAdded.bands[0].type === 'peaking' &&
bandNear(eqAdded.bands[0], 1000, 6) &&
eqAdded.fields.type === 'Peaking' &&
eqAdded.fields.slope === null,
JSON.stringify({ eqShown, eqDocs, eqAdded }),
);
const eqHandle = await json('__gm.eq.handleAt(0)');
const eqTo = await json('__gm.eq.pointAt(4000, -6)');
await drag(eqHandle.x, eqHandle.y, eqTo.x, eqTo.y);
const eqDragged = (await json('__gm.eq.bands()'))[0];
await press('z', { ctrl: true });
const eqUndone = (await json('__gm.eq.bands()'))[0];
await press('z', { ctrl: true, shift: true });
const eqHandleBack = await json('__gm.eq.handleAt(0)');
await t.wheel(eqHandleBack.x, eqHandleBack.y, -100);
const eqWheeled = (await json('__gm.eq.bands()'))[0];
check(
'dragging the band moves it as one undo step, and the wheel narrows it',
bandNear(eqDragged, 4000, -6) &&
bandNear(eqUndone, 1000, 6) &&
bandNear(eqWheeled, 4000, -6) &&
eqWheeled.q > 1,
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
);
// #376: the wheel on free space zooms the curve to ±12 dB, so the band's handle moves out with its
// gain. The zoom is saved on the node, the Q stays, and Ctrl+Z undoes the Q, not the zoom.
const eqFree = await json('__gm.eq.pointAt(50, 12)');
await t.wheel(eqFree.x, eqFree.y, -100);
const eqZoomed = await json(`({
range: __gm.eq.range(),
saved: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes
.find((n) => n.id === 'fx-eq').params.range,
q: __gm.eq.bands()[0].q,
handle: __gm.eq.handleAt(0),
at: __gm.eq.pointAt(4000, -6),
})`);
await press('z', { ctrl: true });
const eqZoomUndone = await json('({ range: __gm.eq.range(), q: __gm.eq.bands()[0].q })');
await press('z', { ctrl: true, shift: true });
check(
'the wheel on free space zooms the curve to ±12 dB, saved on the node, and Ctrl+Z leaves the zoom',
eqZoomed.range === 12 &&
eqZoomed.saved === 12 &&
eqZoomed.q === eqWheeled.q &&
eqZoomed.handle.y > eqHandleBack.y + 10 &&
Math.abs(eqZoomed.handle.y - eqZoomed.at.y) < 1 &&
eqZoomUndone.range === 12 &&
eqZoomUndone.q < eqWheeled.q,
JSON.stringify({ eqHandleBack, eqZoomed, eqZoomUndone }),
);
// #377: at 1000 ms a frame after the wheel the dB are on their way from ±12 to ±6, and the node
// holds the step at once.
const eqZoom = await sampleZoom(
t,
eqFree,
`({ scale: __gm.eq.zoomShown().range, moving: __gm.eq.zoomShown().moving, saved: __gm.eq.range() })`,
);
check(
'a zoom of the curve is shown on its way and lands where the jump does',
eqZoom.ok && eqZoom.samples.start.scale === 12 && eqZoom.samples.midway.saved === 6,
eqZoom.detail,
);
await shot('34-parametric-eq');
// #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node:
// the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not
// seen, no tap feeds an analyser any more, though the voice still plays.
await evaluate(`__gm.floatDocument(${JSON.stringify(`eq:${fxId}:fx-eq`)})`);
await evaluate('__gm.soundfx.setParam("depth", 0)');
await evaluate('__gm.sound.stopAll()');
const framesBefore = (await json('__gm.eq.spectrum()')).frames;
await evaluate('__gm.soundfx.play()');
const spectrum = await waitFor(
() => json('__gm.eq.spectrum()'),
(s) => s.frames >= framesBefore + 3,
);
await shot('34-eq-spectrum');
const nearBlip = (hz) => Math.abs(hz - 880) <= spectrum.binHz;
check(
'floated beside the Sound FX tab, the EQ draws the blip’s 880 Hz as the loudest bin on both sides',
spectrum.taps.listened === `${fxId}:fx-eq` &&
spectrum.frames >= framesBefore + 3 &&
nearBlip(spectrum.peakIn) &&
nearBlip(spectrum.peakOut) &&
spectrum.levelOut > -60,
JSON.stringify(spectrum),
);
const eqWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'eq');
await click(eqWindow.dock.x, eqWindow.dock.y);
const unseen = await evaluate(`(async () => {
const frame = () => new Promise((done) => requestAnimationFrame(() => done()));
__gm.sound.stopAll();
const before = __gm.sound.stats().started;
__gm.soundfx.play();
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
await frame();
const seen = __gm.eq.spectrum().taps;
// Not the sound FX graph: the node there listens to the same tap (#381).
__gm.showTab('scene');
await frame();
await frame();
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
})()`);
check(
'once the EQ’s tab is not seen, no tap feeds an analyser, though the voice still plays',
unseen.seen.held >= 2 &&
unseen.seen.feeding === unseen.seen.held &&
unseen.unseen.listened === '' &&
unseen.unseen.held >= 2 &&
unseen.unseen.feeding === 0 &&
unseen.playing === 1,
JSON.stringify(unseen),
);
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
// #381: the EQ node shows the panel of its tab at full size, its inputs on the left and its output
// on the right. A double-click on its curve adds a band without opening the tab, a drag is one
// undo step, the wheel over a band zooms the graph and leaves its Q and the dB alone (#383),
// and a right-click takes the band. Played, the node
// draws the spectrum, its tab open in the row.
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const eqPanel = () => json("__gm.eq.panel('fx-eq')");
const eqFace = await eqPanel();
const eqRect = eqFace.rect;
const besideEq = (p) => p.y > eqRect.y && p.y < eqRect.y + eqRect.h;
check(
'a Parametric EQ node shows the panel of its tab at full size, its inputs on the left of it and its output on the right',
eqFace.shown &&
eqFace.size.w === 720 &&
eqFace.size.h === 360 &&
Object.values(eqFace.inputs).length >= 1 &&
Object.values(eqFace.inputs).every((p) => p.x < eqRect.x && besideEq(p)) &&
Object.values(eqFace.outputs).length === 1 &&
Object.values(eqFace.outputs).every((p) => p.x > eqRect.x + eqRect.w && besideEq(p)) &&
eqFace.curve.h > eqRect.h / 2 &&
eqFace.bands.length === 1,
JSON.stringify(eqFace),
);
const eqNodeAt = (hz, db) => json(`__gm.eq.panelPointAt('fx-eq', ${hz}, ${db})`);
const eqAddAt = await eqNodeAt(250, 6);
await t.doubleClick(eqAddAt.x, eqAddAt.y);
const eqNodeAdded = { panel: await eqPanel(), docs: await json('__gm.documents()') };
const eqNodeTo = await eqNodeAt(500, -3);
const eqNodeHandle = eqNodeAdded.panel.handles[1];
await drag(eqNodeHandle.x, eqNodeHandle.y, eqNodeTo.x, eqNodeTo.y);
const eqNodeDragged = (await eqPanel()).bands[1];
await press('z', { ctrl: true });
const eqNodeUndone = (await eqPanel()).bands[1];
const eqRangeBefore = await json('__gm.eq.range()');
await t.wheel(eqNodeHandle.x, eqNodeHandle.y, -100);
const eqNodeWheeled = { panel: await eqPanel(), range: await json('__gm.eq.range()') };
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const eqNodeLast = (await eqPanel()).handles[1];
await click(eqNodeLast.x, eqNodeLast.y, 'right');
const eqNodeRemoved = await eqPanel();
check(
'in the node, a double-click on the curve adds a band without opening the tab, a drag moves it as one undo step, the wheel over it zooms the graph and leaves its Q, and a right-click takes it',
eqNodeAdded.panel.bands.length === 2 &&
bandNear(eqNodeAdded.panel.bands[1], 250, 6) &&
eqNodeAdded.panel.picked === 1 &&
eqNodeAdded.docs.active === `asset:${fxId}` &&
bandNear(eqNodeDragged, 500, -3) &&
bandNear(eqNodeUndone, 250, 6) &&
eqNodeWheeled.panel.bands[1].q === eqNodeUndone.q &&
eqNodeWheeled.panel.rect.w > eqRect.w + 10 &&
eqNodeWheeled.range === eqRangeBefore &&
eqNodeRemoved.bands.length === 1 &&
bandNear(eqNodeRemoved.bands[0], 4000, -6),
JSON.stringify({ eqNodeAdded, eqNodeDragged, eqNodeUndone, eqNodeWheeled, eqNodeRemoved }),
);
await evaluate('__gm.soundfx.fit()');
await evaluate('__gm.sound.stopAll()');
const eqNodeFrames = (await eqPanel()).spectrum.frames;
await evaluate('__gm.soundfx.play()');
const eqNodeHeard = await waitFor(eqPanel, (p) => p.spectrum.frames >= eqNodeFrames + 3);
await evaluate('__gm.sound.stopAll()');
check(
'played, the EQ node draws the blip’s 880 Hz behind its curve, its tab open in the row',
eqNodeHeard.spectrum.frames >= eqNodeFrames + 3 &&
nearBlip(eqNodeHeard.spectrum.peakOut) &&
(await json('__gm.documents()')).docs.includes(`eq:${fxId}:fx-eq`),
JSON.stringify(eqNodeHeard.spectrum),
);
await shot('34-eq-node');
// #385: the EQ node is resized by its lower right corner, one undo step: small, the row under its
// curve goes, and a little larger it comes back with what fits of it, from the left.
const eqCorner = (await eqPanel()).corner;
await drag(eqCorner.x, eqCorner.y, eqCorner.x - 600, eqCorner.y - 400);
const eqLeast = {
panel: await eqPanel(),
size: await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-eq').size`,
),
};
await press('z', { ctrl: true });
const eqFull = await eqPanel();
const zoom = eqFull.rect.w / eqFull.size.w;
await drag(eqFull.corner.x, eqFull.corner.y, eqFull.corner.x - 300 * zoom, eqFull.corner.y - 160 * zoom);
const eqMid = await eqPanel();
const eqRow = ['Band', 'Type', 'Hz', 'dB', 'Q', 'Slope', 'End'];
const leftPart = (row) => row.every((name, i) => name === eqRow[i]);
check(
'an EQ node is resized by its corner as one undo step: small, its row goes; larger, the groups that fit come back from the left',
eqLeast.size?.w === 240 &&
eqLeast.size.h === 120 &&
eqLeast.panel.size.w === 240 &&
eqLeast.panel.rows.row.length === 0 &&
eqFull.size.w === 720 &&
eqFull.rows.row.join() === eqRow.join() &&
eqFull.rows.help === false &&
eqMid.size.w < 720 &&
eqMid.rows.row.length >= 1 &&
eqMid.rows.row.length < eqRow.length &&
leftPart(eqMid.rows.row),
JSON.stringify({
eqLeast,
eqFull: { size: eqFull.size, rows: eqFull.rows },
eqMid: { size: eqMid.size, rows: eqMid.rows },
}),
);
await press('z', { ctrl: true });
// #385: a Filter node is edited in its node as one band of the curve: its handle is dragged for the
// frequency and the gain as one undo step. A double-click on its title opens the same in a tab,
// Delete there leaves the node, and a small window draws what fits of the row and no help.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-filter', 'SoundFxFilter', -240, 600, { filter: 'peaking', cutoff: 600, gain: -8, q: 2 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e11');
graph.edges.push(
${JSON.stringify(edge('fx-e12', 'fx-eq', 'out', 'fx-filter', 'in'))},
${JSON.stringify(edge('fx-e13', 'fx-filter', 'out', 'fx-out', 'in'))},
);`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const filterPanel = () => json("__gm.eq.panel('fx-filter')");
const filterFace = await filterPanel();
const filterRect = filterFace.rect;
const filterTo = await json("__gm.eq.panelPointAt('fx-filter', 2000, 6)");
await drag(filterFace.handles[0].x, filterFace.handles[0].y, filterTo.x, filterTo.y);
const filterDragged = await filterPanel();
await press('z', { ctrl: true });
const filterUndone = await filterPanel();
check(
'a Filter node shows its curve with one band between its ports, dragged for frequency and gain as one undo step',
filterFace.shown &&
filterFace.size.w === 560 &&
filterFace.size.h === 280 &&
Object.values(filterFace.inputs).every((p) => p.x < filterRect.x) &&
Object.values(filterFace.outputs).every((p) => p.x > filterRect.x + filterRect.w) &&
filterFace.rows.row.join() === 'Type,Hz,dB,Q,Slope' &&
filterFace.fields.type === 'Peaking' &&
bandNear(filterFace.bands[0], 600, -8) &&
bandNear(filterDragged.bands[0], 2000, 6) &&
bandNear(filterUndone.bands[0], 600, -8) &&
filterDragged.bands[0].q === 2,
JSON.stringify({ filterFace, filterDragged: filterDragged.bands, filterUndone: filterUndone.bands }),
);
const filterTitle = await json("__gm.soundfx.nodeAt('fx-filter')");
await t.doubleClick(filterTitle.x, filterTitle.y);
const filterTab = {
shown: await json('__gm.filter.shown()'),
rows: await json('__gm.filter.rows()'),
docs: await json('__gm.documents()'),
};
await press('Delete');
const filterKept = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.some((n) => n.id === 'fx-filter')`,
);
await evaluate(`__gm.floatDocument(${JSON.stringify(`filter:${fxId}:fx-filter`)})`);
const filterWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
await drag(filterWindow.grip.x, filterWindow.grip.y, filterWindow.box.x + 470, filterWindow.box.y + 250);
const filterSmall = { rows: await json('__gm.filter.rows()'), labels: await json('__gm.filter.labels()') };
await shot('34-filter-window');
const filterRow = ['Type', 'Hz', 'dB', 'Q', 'Slope'];
check(
'a double-click opens the Filter in a tab with its help, Delete there leaves the node, and a small window draws what fits of the row and no help',
filterTab.shown?.nodeId === 'fx-filter' &&
filterTab.docs.active === `filter:${fxId}:fx-filter` &&
filterTab.rows.row.join() === filterRow.join() &&
filterTab.rows.help === true &&
filterKept &&
filterSmall.rows.help === false &&
filterSmall.rows.row.length >= 1 &&
filterSmall.rows.row.length < filterRow.length &&
filterSmall.rows.row.every((name, i) => name === filterRow[i]),
JSON.stringify({ filterTab, filterKept, filterSmall }),
);
// #385: played, the Filter node and its window both draw what passes it, as the EQ does.
await evaluate('__gm.sound.stopAll()');
const filterSpectra = async () => ({
node: (await filterPanel()).spectrum,
tab: await json('__gm.filter.spectrum()'),
});
const filterFrames = await filterSpectra();
await evaluate('__gm.soundfx.play()');
const filterHeard = await waitFor(
filterSpectra,
(s) => s.node.frames >= filterFrames.node.frames + 3 && s.tab.frames >= filterFrames.tab.frames + 3,
);
await evaluate('__gm.sound.stopAll()');
check(
'played, the Filter node and its window draw the blip’s 880 Hz behind the curve',
filterHeard.node.frames >= filterFrames.node.frames + 3 &&
filterHeard.tab.frames >= filterFrames.tab.frames + 3 &&
nearBlip(filterHeard.node.peakIn) &&
nearBlip(filterHeard.tab.peakIn) &&
filterHeard.tab.taps.listened.split(' ').includes(`${fxId}:fx-filter`),
JSON.stringify({ filterFrames, filterHeard }),
);
const filterClose = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
await click(filterClose.close.x, filterClose.close.y);
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-filter');
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))});`);
await evaluate('__gm.sound.stopAll()');
const eqBefore = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-eq');
graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
const eqDocsAfter = await json('__gm.documents()');
check(
'the voice passes the gain and the band, and the EQ’s tab goes with its node',
eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')),
JSON.stringify({ last: eqHeard.last, eqDocsAfter }),
);
// #378: a Compressor behind the gain opens a tab of its own as well. Its knobs turn with a drag up
// (one undo step), a notch of the wheel and a double-click back to the default, and take a
// typed number. The end of its curve is dragged for the ratio. Played, the level going out
// stays under the one coming in, and a knob whose input is linked is greyed.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-comp', 'SoundFxCompressor', -240, 0, {
threshold: -30,
ratio: 4,
knee: 0,
}),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e12', 'fx-gain', 'out', 'fx-comp', 'in'))},
${JSON.stringify(edge('fx-e13', 'fx-comp', 'out', 'fx-out', 'in'))},
);`);
const compNode = await json("__gm.soundfx.nodeAt('fx-comp')");
await t.doubleClick(compNode.x, compNode.y);
const compShown = await json(
'({ shown: __gm.compressor.shown(), docs: __gm.documents(), knobs: __gm.compressor.knobs() })',
);
check(
'a double-click opens the Compressor in a tab, a knob for each of its numbers',
compShown.shown?.nodeId === 'fx-comp' &&
compShown.docs.active === `compressor:${fxId}:fx-comp` &&
compShown.knobs.threshold.value === -30 &&
compShown.knobs.ratio.value === 4 &&
compShown.knobs.knee.value === 0 &&
compShown.knobs.attack.value === 0.003 &&
Object.keys(compShown.knobs).length === 6,
JSON.stringify(compShown),
);
const thresholdDial = await json("__gm.compressor.dialAt('threshold')");
await drag(thresholdDial.x, thresholdDial.y, thresholdDial.x, thresholdDial.y - 20);
const compDragged = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true });
const compUndone = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true, shift: true });
const ratioDial = await json("__gm.compressor.dialAt('ratio')");
await t.wheel(ratioDial.x, ratioDial.y, -100);
const kneeDial = await json("__gm.compressor.dialAt('knee')");
await t.doubleClick(kneeDial.x, kneeDial.y);
const makeupField = await json("__gm.compressor.fieldAt('makeup')");
await click(makeupField.x, makeupField.y);
await t.typeText('3');
await press('Enter');
await sleep(150);
const compTurned = await json('({ numbers: __gm.compressor.numbers(), knobs: __gm.compressor.knobs() })');
check(
'a knob turns up with a drag up as one undo step, a notch of the wheel, a double-click and a typed number',
compDragged.threshold === -20 &&
compUndone.threshold === -30 &&
compTurned.numbers.threshold === -20 &&
compTurned.numbers.ratio > 4 &&
compTurned.numbers.ratio < 5 &&
compTurned.numbers.knee === 6 &&
compTurned.numbers.makeup === 3 &&
compTurned.knobs.makeup.value === 3,
JSON.stringify({ compDragged, compUndone, compTurned }),
);
// Auto-gain is a switch (#378): a click turns it on as one undo step, and it says what it gives.
const autoOff = await json('__gm.compressor.autoGain()');
await click(autoOff.at.x, autoOff.at.y);
await sleep(100);
const autoOn = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })');
await press('z', { ctrl: true });
await sleep(100);
const autoUndone = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })');
check(
'the auto-gain switch turns on with a click as one undo step and says the dB it gives',
!autoOff.on &&
autoOff.says === 'off' &&
autoOn.on &&
autoOn.numbers.autoGain === true &&
/^\+\d+\.\d dB$/.test(autoOn.says) &&
!autoUndone.on &&
autoUndone.numbers.autoGain === false &&
autoUndone.says === 'off',
JSON.stringify({ autoOff, autoOn, autoUndone }),
);
const ratioHandle = await json("__gm.compressor.handleAt('ratio')");
const ratioTo = await json('__gm.compressor.pointAt(0, -10)');
await drag(ratioHandle.x, ratioHandle.y, ratioTo.x, ratioTo.y);
const compCurve = await json(
"({ numbers: __gm.compressor.numbers(), handle: __gm.compressor.handleAt('ratio'), at: __gm.compressor.pointAt(0, -10) })",
);
await press('z', { ctrl: true });
const compCurveUndone = await json('__gm.compressor.numbers()');
check(
'dragging the end of the curve sets the ratio that ends there, as one undo step',
compCurve.numbers.ratio < compTurned.numbers.ratio &&
Math.abs(compCurve.handle.y - compCurve.at.y) < 2 &&
compCurveUndone.ratio === compTurned.numbers.ratio,
JSON.stringify({ compCurve, compCurveUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
Object.assign(graph.nodes.find((n) => n.id === 'fx-comp').params, { threshold: -50, ratio: 8, knee: 0, makeup: 0 });`);
await evaluate('__gm.sound.stopAll()');
const levelsBefore = (await json('__gm.compressor.levels()')).frames;
await evaluate('__gm.soundfx.play()');
const compLevels = await waitFor(
() => json('__gm.compressor.levels()'),
(l) => l.frames >= levelsBefore + 3 && l.reduction > 3,
);
await shot('34-compressor');
check(
'played, the tab reads the level on both sides of the node, the one going out squeezed under the one coming in',
compLevels.taps.listened === `${fxId}:fx-comp` &&
compLevels.in > -50 &&
compLevels.out < compLevels.in &&
compLevels.reduction > 3,
JSON.stringify(compLevels),
);
await evaluate('__gm.sound.stopAll()');
// Floated and sized down as far as it goes, the window keeps every knob; sized up, the knobs stay on
// the bottom line and the curve in the middle of the height to its right.
await evaluate(`__gm.floatDocument(${JSON.stringify(`compressor:${fxId}:fx-comp`)})`);
const compWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor');
const compLayout = async () => {
const win = await compWindow();
const at = await json(
"({ first: __gm.compressor.dialAt('threshold'), last: __gm.compressor.autoGain().at, ...__gm.compressor.pictures() })",
);
const inside = (p) => p.x > win.box.x && p.x < win.box.x + win.box.w && p.y < win.box.y + win.box.h - 20;
return { box: win.box, ...at, inside: inside(at.first) && inside(at.last) };
};
let floatedComp = await compWindow();
await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x - 2000, floatedComp.grip.y - 2000);
const compSmall = await compLayout();
floatedComp = await compWindow();
await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x + 2000, floatedComp.grip.y + 2000);
const compLarge = await compLayout();
const { levels: bigLevels, curve: bigCurve } = compLarge;
check(
'a floated Compressor holds every knob at its least size; sized up, the knobs stay at the bottom, the levels fill the height and the curve sits in its middle',
compSmall.box.w > 320 &&
compSmall.box.h > 200 &&
compSmall.inside &&
compLarge.box.h > compSmall.box.h + 100 &&
compLarge.inside &&
compLarge.box.y + compLarge.box.h - compLarge.last.y < 100 &&
bigLevels.h > compSmall.levels.h + 100 &&
bigCurve.h === bigCurve.w &&
Math.abs(bigCurve.y + bigCurve.h / 2 - (bigLevels.y + bigLevels.h / 2)) <= 1,
JSON.stringify({ compSmall, compLarge }),
);
// Read again: Dock moved right with the window's edge.
floatedComp = await compWindow();
await click(floatedComp.dock.x, floatedComp.dock.y);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`);
const compLinked = await json('__gm.compressor.knobs()');
// Not the sound FX graph: the node there listens to the same taps (#380).
await showTab('scene');
await sleep(100);
const compUnseen = await json('__gm.compressor.levels()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-comp');
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13', 'fx-e14'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
const compDocsAfter = await json('__gm.documents()');
check(
'a linked input greys its knob, an unseen tab feeds no analyser, and the tab goes with its node',
!compLinked.ratio.enabled &&
compLinked.threshold.enabled &&
compUnseen.taps.listened === '' &&
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
);
// #379, #380: a Compressor node is drawn as its device – its inputs on the left, its output on the
// right, the panel between them. Its knobs and its lever work right in the graph, and
// played, the meter's needle swings and the LEDs light. A double-click opens the tab of #378.
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
);`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const hwPanel = () => json("__gm.compressor.panel('fx-hw')");
const hwShown = await hwPanel();
const hwRect = hwShown.rect;
const hwInputs = Object.values(hwShown.inputs);
const hwOutputs = Object.values(hwShown.outputs);
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
check(
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right, its numbers as plain labels, the ratio as 8:1',
hwShown.shown &&
hwShown.numbers.ratio === '8:1' &&
hwShown.numbers.threshold === '-50' &&
hwShown.size.w === 820 &&
hwShown.size.h === 280 &&
hwInputs.length === 8 &&
hwInputs.every((p) => p.x < hwRect.x && besideThePanel(p)) &&
hwOutputs.length === 1 &&
hwOutputs.every((p) => p.x > hwRect.x + hwRect.w && besideThePanel(p)) &&
hwShown.node.w > hwRect.w &&
hwShown.node.h > hwRect.h,
JSON.stringify(hwShown),
);
const hwThreshold = hwShown.dials.threshold;
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
const hwNumbers = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-hw').params`,
);
const hwDragged = await hwNumbers();
await press('z', { ctrl: true });
const hwUndone = await hwNumbers();
const hwRatio = hwShown.dials.ratio;
await t.wheel(hwRatio.x, hwRatio.y, -100);
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const hwRefit = (await hwPanel()).dials.ratio;
await t.doubleClick(hwRefit.x, hwRefit.y);
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
await sleep(100);
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
check(
'in the node, a knob turns with a drag up as one undo step, the wheel over it zooms the graph and leaves it, a double-click resets it without opening the tab, and the lever switches auto-gain',
hwDragged.threshold > -50 &&
hwUndone.threshold === -50 &&
hwWheeled.numbers.ratio === 8 &&
hwWheeled.panel.rect.w > hwRect.w + 10 &&
hwReset.numbers.ratio === 4 &&
hwReset.docs.active === `asset:${fxId}` &&
hwLever.numbers.autoGain === true &&
hwLever.panel.autoGain.on &&
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
JSON.stringify({ hwDragged, hwUndone, hwWheeled, hwReset, hwLever }),
);
await press('z', { ctrl: true });
await evaluate('__gm.sound.stopAll()');
await evaluate('__gm.soundfx.play()');
const hwPlayed = await waitFor(hwPanel, (p) => p.seen.needle > 3 && p.seen.leds > 0);
await shot('34-compressor-node');
check(
'played, the needle in the node swings towards what is taken away and the LEDs light with the level going out',
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
JSON.stringify(hwPlayed),
);
await evaluate('__gm.sound.stopAll()');
const hwTitle = await json("__gm.soundfx.nodeAt('fx-hw')");
await t.doubleClick(hwTitle.x, hwTitle.y);
await sleep(100);
const hwTab = {
docs: await json('__gm.documents()'),
windows: await json('__gm.floatWindows()'),
shown: await json('__gm.compressor.shown()'),
};
// The tab and the node listen to the same taps: the tab, open but not seen, must leave them be.
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await sleep(100);
await evaluate('__gm.soundfx.play()');
const hwBesideTab = await waitFor(hwPanel, (p) => p.seen.needle > 3);
await evaluate('__gm.sound.stopAll()');
check(
'a double-click on the node opens its tab in the row, and with the tab open the node’s needle still swings',
hwTab.docs.active === `compressor:${fxId}:fx-hw` &&
hwTab.windows.length === 0 &&
hwTab.shown?.nodeId === 'fx-hw' &&
hwBesideTab.seen.needle > 3,
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
);
// #384: a Gain node is drawn as an orange device with its one knob, worked as the Compressor's.
// The Compressor's node is wide enough to lie over the Gain's: the Gain goes below it here.
const gainUnder = await json("__gm.compressor.panel('fx-hw').node");
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-gain').y = 400;`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const gainPanel = () => json("__gm.gain.panel('fx-gain')");
const gainShown = await gainPanel();
const gainRect = gainShown.rect;
const gainBeside = (p) => p.y > gainRect.y && p.y < gainRect.y + gainRect.h;
const gainDb = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-gain').params.gain`,
);
const gainDial = gainShown.knob.dial;
await drag(gainDial.x, gainDial.y, gainDial.x, gainDial.y - 20);
const gainDragged = await gainDb();
await press('z', { ctrl: true });
const gainUndone = await gainDb();
await t.wheel(gainDial.x, gainDial.y, -100);
const gainWheeled = { db: await gainDb(), panel: await gainPanel() };
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const gainRefit = (await gainPanel()).knob.dial;
await t.doubleClick(gainRefit.x, gainRefit.y);
const gainReset = { db: await gainDb(), docs: await json('__gm.documents()') };
await press('z', { ctrl: true });
check(
'a Gain node shows its panel with one knob between its ports; a drag turns it as one undo step, the wheel zooms the graph, a double-click resets it without a tab',
gainShown.shown &&
gainShown.size.w === 160 &&
gainShown.size.h === 160 &&
gainShown.knob.number === '-6' &&
gainShown.knob.enabled &&
Object.values(gainShown.inputs).length === 3 &&
Object.values(gainShown.inputs).every((p) => p.x < gainRect.x && gainBeside(p)) &&
Object.values(gainShown.outputs).every((p) => p.x > gainRect.x + gainRect.w && gainBeside(p)) &&
gainDragged > -6 &&
gainUndone === -6 &&
gainWheeled.db === -6 &&
gainWheeled.panel.rect.w > gainRect.w + 5 &&
gainReset.db === 0 &&
gainReset.docs.active === `asset:${fxId}`,
JSON.stringify({ gainUnder, gainShown, gainDragged, gainUndone, gainWheeled, gainReset }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-gain').y = 0;`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-hw');
graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #389: a Reverb node is drawn as a device with a round screen of its tail. A knob its type does
// not take goes over to Custom with the type's numbers, as one undo step; a sound picked on
// it makes the type Sound and the screen draws that sound's tail. It is wired to nothing.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-rv', 'SoundFxReverb', 0, 400, { room: 'hall' }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const reverbPanel = () => json("__gm.reverb.panel('fx-rv')");
const reverbParams = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-rv').params`,
);
const rvShown = await reverbPanel();
const rvRect = rvShown.rect;
const rvBeside = (p) => p.y > rvRect.y && p.y < rvRect.y + rvRect.h;
const rvDial = rvShown.knobs.decay.dial;
await drag(rvDial.x, rvDial.y, rvDial.x, rvDial.y - 20);
const rvTurned = { params: await reverbParams(), panel: await reverbPanel() };
await press('z', { ctrl: true });
const rvUndone = await reverbParams();
await click(rvShown.rooms.cave.x, rvShown.rooms.cave.y);
const rvCave = { params: await reverbParams(), panel: await reverbPanel() };
await click(rvCave.panel.sound.at.x, rvCave.panel.sound.at.y);
const rvOffered = (await json('__gm.menuRows()')) ?? [];
const rvPick = rvOffered.find((row) => row);
const rvPickAt = await t.locate(rvPick, true);
await click(rvPickAt.x, rvPickAt.y);
const rvSound = {
params: await reverbParams(),
panel: await waitFor(reverbPanel, (p) => p.scope.kind === 'sound'),
};
await press('z', { ctrl: true });
const rvSoundUndone = await reverbParams();
check(
'a Reverb node shows its device between its ports; a knob of a room goes to Custom with its numbers as one undo step, a type button switches, a picked sound makes it Sound and draws that sound',
rvShown.shown &&
rvShown.size.w === 640 &&
rvShown.size.h === 280 &&
rvShown.room === 'hall' &&
['decay', 'damping', 'predelay', 'impulse'].every((key) => rvShown.idle.includes(key)) &&
Object.keys(rvShown.inputs).join() === 'in,mix' &&
Object.values(rvShown.inputs).every((p) => p.x < rvRect.x && rvBeside(p)) &&
Object.values(rvShown.outputs).every((p) => p.x > rvRect.x + rvRect.w && rvBeside(p)) &&
rvShown.scope.kind === 'made' &&
rvShown.scope.reach > 0.48 &&
rvShown.scope.reach < 0.5 &&
rvShown.scope.says.ends === 'ENDS 2.52 s' &&
rvShown.highs === 'HIGHS 5.5 kHz' &&
rvTurned.params.room === 'custom' &&
rvTurned.params.decay > 2.5 &&
rvTurned.params.damping === 0.35 &&
rvTurned.params.predelay === 0.02 &&
Object.keys(rvTurned.panel.inputs).length === 5 &&
rvTurned.panel.idle.join() === 'impulse' &&
rvTurned.panel.scope.reach > rvShown.scope.reach &&
rvUndone.room === 'hall' &&
rvUndone.decay === undefined &&
rvCave.params.room === 'cave' &&
rvCave.panel.scope.reach > 0.68 &&
Boolean(rvPick) &&
rvSound.params.room === 'sound' &&
Boolean(rvSound.params.impulse) &&
rvSound.panel.sound.text === rvPick &&
rvSound.panel.scope.kind === 'sound' &&
rvSound.panel.scope.rings > 0 &&
rvSoundUndone.room === 'cave' &&
rvSoundUndone.impulse === undefined,
JSON.stringify({ rvShown, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-rv');`);
// #390: a Pitch is drawn as the Gain's device in green, a Mix in blue with a lever for its curve.
// The Pitch in the chain has its semitones wired, so its knob is locked; a free one turns.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(
${JSON.stringify(node('fx-pt', 'SoundFxPitch', 0, 400, { semitones: -12 }))},
${JSON.stringify(node('fx-mx', 'SoundFxMix', 400, 400, { mix: 0.3 }))},
);`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const knobParams = (id) =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === '${id}').params`,
);
const ptWired = await json("__gm.pitch.panel('fx-pitch')");
const ptShown = await json("__gm.pitch.panel('fx-pt')");
await drag(ptShown.knob.dial.x, ptShown.knob.dial.y, ptShown.knob.dial.x, ptShown.knob.dial.y - 20);
const ptTurned = (await knobParams('fx-pt')).semitones;
await press('z', { ctrl: true });
const ptUndone = (await knobParams('fx-pt')).semitones;
const mxShown = await json("__gm.mix.panel('fx-mx')");
const mxRect = mxShown.rect;
await click(mxShown.lever.at.x, mxShown.lever.at.y);
const mxBoth = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") };
await press('z', { ctrl: true });
const mxUndone = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") };
await drag(mxShown.knob.dial.x, mxShown.knob.dial.y, mxShown.knob.dial.x, mxShown.knob.dial.y - 20);
const mxTurned = (await knobParams('fx-mx')).mix;
await press('z', { ctrl: true });
const mxTurnUndone = (await knobParams('fx-mx')).mix;
check(
'a Pitch node is a green one-knob device, locked while its semitones are wired; a Mix node is a blue one whose lever switches the curve, each change one undo step',
ptWired.size.w === 160 &&
!ptWired.knob.enabled &&
ptWired.lever === null &&
ptShown.knob.enabled &&
ptShown.knob.number === '-12' &&
ptTurned > -12 &&
ptUndone === -12 &&
mxShown.size.w === 180 &&
mxShown.size.h === 200 &&
mxShown.knob.number === '0.3' &&
// The lever stands above the knob, both in the middle of the plate.
mxShown.lever.at.y < mxShown.knob.dial.y &&
Math.abs(mxShown.lever.at.x - mxShown.knob.dial.x) < 1 &&
Object.values(mxShown.inputs).length === 3 &&
Object.values(mxShown.inputs).every((p) => p.x < mxRect.x) &&
Object.values(mxShown.outputs).every((p) => p.x > mxRect.x + mxRect.w) &&
mxShown.lever.on === false &&
mxShown.lever.says === 'crossfade' &&
mxBoth.params.curve === 'both' &&
mxBoth.panel.lever.on &&
mxBoth.panel.lever.says === 'both at center' &&
mxUndone.params.curve !== 'both' &&
mxUndone.panel.lever.says === 'crossfade' &&
mxTurned > 0.3 &&
mxTurnUndone === 0.3,
JSON.stringify({
ptWired,
ptShown,
ptTurned,
ptUndone,
mxShown,
mxBoth,
mxUndone,
mxTurned,
mxTurnUndone,
}),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => !['fx-pt', 'fx-mx'].includes(n.id));`);
// #392: a Pan is the same device in cyan, its 0 straight up, L and R at the ends of the turn.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-pn', 'SoundFxPan', 0, 400, { pan: -0.35 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const pnShown = await json("__gm.pan.panel('fx-pn')");
await drag(pnShown.knob.dial.x, pnShown.knob.dial.y, pnShown.knob.dial.x, pnShown.knob.dial.y - 20);
const pnTurned = (await knobParams('fx-pn')).pan;
await press('z', { ctrl: true });
const pnUndone = (await knobParams('fx-pn')).pan;
check(
'a Pan node is a cyan one-knob device whose knob turns the pan, one undo step',
pnShown.size.w === 160 &&
pnShown.size.h === 160 &&
pnShown.lever === null &&
pnShown.knob.enabled &&
pnShown.knob.number === '-0.35' &&
Object.values(pnShown.inputs).every((p) => p.x < pnShown.rect.x) &&
Object.values(pnShown.outputs).every((p) => p.x > pnShown.rect.x + pnShown.rect.w) &&
pnTurned > -0.35 &&
pnTurned <= 1 &&
pnUndone === -0.35,
JSON.stringify({ pnShown, pnTurned, pnUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-pn');`);
// #393: a Delay is a device of its own size with its echoes on a screen and a light running over
// them at their beat. Ping-pong puts them left and right by turns, Sync names the time as a
// note, the time knob turns it; each is one undo step.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-dl', 'SoundFxDelay', 0, 400, { time: 0.5, feedback: 0.5, mix: 0.5 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const dlShown = await json("__gm.delay.panel('fx-dl')");
await sleep(150);
const dlLater = (await json("__gm.delay.panel('fx-dl')")).scope.at;
await click(dlShown.pingPong.at.x, dlShown.pingPong.at.y);
const dlPingPong = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
await click(dlShown.sync.at.x, dlShown.sync.at.y);
const dlBeats = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
const dlTime = dlShown.knobs.time.dial;
await drag(dlTime.x, dlTime.y, dlTime.x, dlTime.y - 20);
const dlTurned = (await knobParams('fx-dl')).time;
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
const dlUndone = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
const dlSides = (panel) =>
panel.scope.echoes
.slice(0, 3)
.map((e) => e.side)
.join(' ');
check(
'a Delay node is a device with its echoes on a screen and a running light; ping-pong, sync and the time knob are one undo step each',
dlShown.size.w === 680 &&
dlShown.size.h === 280 &&
Object.values(dlShown.inputs).length === 5 &&
Object.values(dlShown.inputs).every((p) => p.x < dlShown.rect.x) &&
Object.values(dlShown.outputs).every((p) => p.x > dlShown.rect.x + dlShown.rect.w) &&
dlShown.knobs.time.number === '500 ms' &&
dlShown.scope.heard === 10 &&
dlShown.scope.echoes.length === 10 &&
dlShown.scope.says.every === 'EVERY 500 ms' &&
dlShown.scope.says.ends === 'ENDS 5 s' &&
dlSides(dlShown) === 'both both both' &&
// The light runs on by itself.
dlLater !== dlShown.scope.at &&
dlPingPong.params.pingPong === true &&
dlPingPong.panel.pingPong.lit &&
dlSides(dlPingPong.panel) === 'left right left' &&
dlBeats.params.sync === 'beats' &&
dlBeats.panel.sync.says === 'beats' &&
dlBeats.panel.knobs.time.number === '1/8' &&
dlBeats.panel.scope.says.every === 'EVERY 1/8 · 250 ms AT 120 BPM' &&
dlTurned > 0.5 &&
dlUndone.params.time === 0.5 &&
dlUndone.params.sync !== 'beats' &&
dlUndone.params.pingPong !== true &&
dlUndone.panel.knobs.time.number === '500 ms' &&
dlSides(dlUndone.panel) === 'both both both',
JSON.stringify({ dlShown, dlLater, dlPingPong, dlBeats, dlTurned, dlUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-dl');`);
// #396: a Saturation is a device of its own size with the wave a sine comes out as on a screen,
// glowing hotter the more it is driven. A type button, the auto-gain lever and the drive
// knob are one undo step each.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-st', 'SoundFxSaturation', 0, 400, { drive: 12 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const stShown = await json("__gm.saturation.panel('fx-st')");
await sleep(150);
const stLater = (await json("__gm.saturation.panel('fx-st')")).scope.flare;
await click(stShown.types.hard.x, stShown.types.hard.y);
const stHard = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
await click(stShown.autoGain.at.x, stShown.autoGain.at.y);
const stOff = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
const stDrive = stShown.knobs.drive.dial;
await drag(stDrive.x, stDrive.y, stDrive.x, stDrive.y - 40);
const stHot = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
const stUndone = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
check(
'a Saturation node is a device whose screen glows hotter with the drive; a type, auto-gain and the drive knob are one undo step each',
stShown.size.w === 720 &&
stShown.size.h === 280 &&
Object.values(stShown.inputs).length === 5 &&
Object.values(stShown.inputs).every((p) => p.x < stShown.rect.x) &&
Object.values(stShown.outputs).every((p) => p.x > stShown.rect.x + stShown.rect.w) &&
stShown.type === 'soft' &&
stShown.knobs.drive.number === '12 dB' &&
stShown.heat.word === 'WARM' &&
stShown.heat.burning &&
stShown.autoGain.lit &&
stShown.scope.says.auto.startsWith('AUTO-GAIN ') &&
// The glow flickers by itself.
stLater !== stShown.scope.flare &&
stHard.params.curve === 'hard' &&
stHard.panel.type === 'hard' &&
stOff.params.autoGain === false &&
stOff.panel.scope.says.auto === 'AUTO-GAIN OFF' &&
stHot.params.drive > 12 &&
stHot.panel.heat.share > stShown.heat.share &&
stHot.panel.scope.embers > 0 &&
stUndone.params.drive === 12 &&
stUndone.params.curve !== 'hard' &&
stUndone.params.autoGain !== false &&
stUndone.panel.type === 'soft' &&
stUndone.panel.heat.word === 'WARM',
JSON.stringify({ stShown, stLater, stHard, stOff, stHot, stUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-st');`);
// #397: a Limiter is a device of its own size whose screen catches what it takes away from the
// moment a sound starts and holds it once the sound ends. Wired in before the output, the
// most it caught is the loudest that came in over its limit. The lever and the threshold
// knob are one undo step each and leave the capture held.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-lm', 'SoundFxLimiter', 0, 400, { threshold: -20 }))});
const last = graph.edges.find((e) => e.to.node === 'fx-out' && e.to.port === 'in');
graph.edges.push({ id: 'fx-e-lm', from: { node: 'fx-lm', port: 'out' }, to: { node: 'fx-out', port: 'in' } });
last.to = { node: 'fx-lm', port: 'in' };`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const lmPanel = () => json("__gm.limiter.panel('fx-lm')");
const lmShown = await lmPanel();
await evaluate('__gm.sound.stopAll()');
await evaluate('__gm.soundfx.play()');
const lmHeld = await waitFor(lmPanel, (p) => p.scope.state === 'held', 40);
await click(lmShown.autoGain.at.x, lmShown.autoGain.at.y);
const lmOff = { params: await knobParams('fx-lm'), panel: await lmPanel() };
const lmDial = lmShown.knobs.threshold.dial;
await drag(lmDial.x, lmDial.y, lmDial.x, lmDial.y - 20);
const lmTurned = (await knobParams('fx-lm')).threshold;
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
const lmUndone = { params: await knobParams('fx-lm'), panel: await lmPanel() };
check(
'a Limiter node holds what it took away once the sound ends, the most of it the loudest over its limit; its lever and threshold are one undo step each',
lmShown.size.w === 720 &&
lmShown.size.h === 280 &&
Object.values(lmShown.inputs).length === 5 &&
Object.values(lmShown.inputs).every((p) => p.x < lmShown.rect.x) &&
Object.values(lmShown.outputs).every((p) => p.x > lmShown.rect.x + lmShown.rect.w) &&
lmShown.knobs.threshold.number === '-20 dB' &&
lmShown.autoGain.lit &&
lmShown.autoGain.says === '+19 dB' &&
lmShown.status === 'WAITING FOR SOUND' &&
lmHeld.status === 'HELD' &&
lmHeld.scope.limited > 0 &&
lmHeld.scope.most !== null &&
lmHeld.scope.loudest > -20 &&
Math.abs(lmHeld.scope.most.db - (lmHeld.scope.loudest + 20)) < 1 &&
/^MOST -\d+(\.\d)? dB AT \d+ ms$/.test(lmHeld.scope.says.most) &&
lmHeld.scope.says.side.startsWith('LASTED ') &&
!lmHeld.lamp &&
lmOff.params.autoGain === false &&
lmOff.panel.autoGain.says === 'off' &&
lmOff.panel.scope.state === 'held' &&
lmTurned > -20 &&
lmUndone.params.threshold === -20 &&
lmUndone.params.autoGain !== false &&
lmUndone.panel.scope.state === 'held' &&
lmUndone.panel.scope.most?.db === lmHeld.scope.most.db,
JSON.stringify({ lmShown, lmHeld, lmOff, lmTurned, lmUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
const into = graph.edges.find((e) => e.to.node === 'fx-lm' && e.to.port === 'in');
into.to = { node: 'fx-out', port: 'in' };
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: 'false', fx: ${JSON.stringify(fxId)} });`);
const beforeGameFx = await stats();
await play();
const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started);
check(
'a Play sound naming the asset plays the game’s voice through it',
gameFx.last?.pitches.join() === '-12' && gameFx.last.stages === 1,
JSON.stringify(gameFx),
);
await stop();
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify(
node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [
{ key: 'depth', kind: 'number' },
]),
)});
graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`);
const beforeGameParam = await stats();
await play();
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
check(
'a Sound FX parameters node hands depth 7 to the game’s voice',
gameParam.last?.pitches.join() === '7' && gameParam.last.stages === 1,
JSON.stringify(gameParam),
);
await stop();
// #315: two looping voices of the same sound start at depth 7, the first keeps its number in a
// variable, and `Set sound FX parameters` hands that one voice depth 3 every frame. In a
// real AudioContext the first one's pitch – the AudioParam of its worklet (#317), in
// semitones – glides there, the second one's stays put.
const tuneIds = ['sn-keep', 'sn-play2', 'sn-tick', 'sn-read', 'sn-tune', 'sn-fxp2'];
const tuneEdges = [
'sn-e-keep',
'sn-e-voice',
'sn-e-play2',
'sn-e-fxp3',
'sn-e-tick',
'sn-e-read',
'sn-e-fxp2',
];
await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
logic.variables = [${JSON.stringify(variable('voice', 'number', 0))}];
const graph = logic.graph;
const play = graph.nodes.find((n) => n.id === 'sn-play');
play.params.loop = 'true';
graph.nodes.push(
${JSON.stringify(node('sn-keep', 'Variables', 520, 0, {}, [written('voice', 'number')]))},
{ ...structuredClone(play), id: 'sn-play2', x: 780 },
${JSON.stringify(node('sn-tick', 'OnUpdate', 0, 300))},
${JSON.stringify(node('sn-read', 'Variables', 0, 420, {}, [port('voice', 'number')]))},
${JSON.stringify(node('sn-tune', 'SetSoundFxParameters', 260, 300))},
${JSON.stringify(
node('sn-fxp2', 'SoundFxParameters', 0, 540, { soundFxAsset: fxId, depth: 3 }, [
{ key: 'depth', kind: 'number' },
]),
)},
);
graph.edges.push(
${JSON.stringify(edge('sn-e-keep', 'sn-play', 'exec', 'sn-keep', 'exec'))},
${JSON.stringify(edge('sn-e-voice', 'sn-play', 'voice', 'sn-keep', 'voice'))},
${JSON.stringify(edge('sn-e-play2', 'sn-keep', 'exec', 'sn-play2', 'exec'))},
${JSON.stringify(edge('sn-e-fxp3', 'sn-fxp', 'params', 'sn-play2', 'fxParams'))},
${JSON.stringify(edge('sn-e-tick', 'sn-tick', 'exec', 'sn-tune', 'exec'))},
${JSON.stringify(edge('sn-e-read', 'sn-read', 'voice', 'sn-tune', 'voice'))},
${JSON.stringify(edge('sn-e-fxp2', 'sn-fxp2', 'params', 'sn-tune', 'fxParams'))},
);`);
const beforeTune = await stats();
await play();
const tunedStats = await waitFor(
stats,
(s) => s.tuned > beforeTune.tuned && s.started >= beforeTune.started + 2,
);
const glided = (levels) =>
Array.isArray(levels) && levels.length === 2 && Math.abs(levels[0][0] - 3) < 0.05;
const levels = await waitFor(() => json(`__gm.sound.levels(${JSON.stringify(blip.file)})`), glided);
check(
'Set sound FX parameters moves the one voice whose number it holds from depth 7 to 3, not the other',
tunedStats.last?.pitches.join() === '7' &&
tunedStats.last.loop === true &&
tunedStats.worklet === true &&
glided(levels) &&
Math.abs(levels[0][1] - 10 ** (-6 / 20)) < 0.001 &&
levels[1][0] === 7,
JSON.stringify({ last: tunedStats.last, tuned: tunedStats.tuned, worklet: tunedStats.worklet, levels }),
);
await stop();
// A `time` of 2 s takes the same voice from depth 7 to -5 evenly on the audio clock, 6 semitones
// a second: half a second in it is still well above the end, where a quick glide would be there.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.find((n) => n.id === 'sn-tune').params.time = 2;
graph.nodes.find((n) => n.id === 'sn-fxp2').params.depth = -5;`);
await play();
const pitch = async () => (await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`))?.[0]?.[0];
const early = await waitFor(pitch, (s) => typeof s === 'number' && s < 6.9);
const later = await waitFor(pitch, (s) => typeof s === 'number' && s < early - 0.6);
await new Promise((resolve) => setTimeout(resolve, 500));
const afterHalf = await pitch();
check(
'Set sound FX parameters with a time ramps the pitch over that time instead of jumping',
early > -1.8 && later > -3.3 && afterHalf > -4.3 && afterHalf < later,
JSON.stringify({ early, later, afterHalf }),
);
await stop();
// The easings of an animation key: Quad in hands the clock a curve, 7 - 3 * t^2 towards -5 in 2 s,
// where linear is 7 - 6 * t. Each reading is timed on the audio clock from the voice's start, so a
// first one that comes in late is held against where the curve is then. Between 0.3 and 1.7 s the
// curve is more than a semitone above linear, and it never falls below it.
await mutate(`
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
Object.assign(tune.params, { ease: 'quad', direction: 'in' });`);
await play();
const pitchNow = () =>
json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`);
const sinceStart = async (seen, since = 'start') => {
const now = await stats();
const from = since === 'tune' ? now.tunedAt : now.last.at;
return seen
.filter((s) => typeof s.pitch === 'number')
.map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) }));
};
const sweep = await sinceStart(await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < -4.3));
const midway = sweep.filter(({ t }) => t > 0.3 && t < 1.7);
check(
'Set sound FX parameters with Quad in ramps slowly first, then on to the end',
midway.length > 0 &&
midway.every(({ t, pitch }) => pitch > 7 - 6 * t + 1) &&
sweep.every(({ t, pitch }) => pitch > 7 - 6 * t - 0.1) &&
sweep.at(-1).pitch < -4.3,
JSON.stringify(sweep),
);
await stop();
// Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the
// audio clock is still 7, the first one past it is the end. Timed from the change, not from the
// start of the voice: task 529 saw it arrive 80 ms later and the 7 held, rightly, to 2.08 s.
await mutate(`
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
Object.assign(tune.params, { ease: 'step', time: 2 });`);
await play();
const steps = await sinceStart(
await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6),
'tune',
);
const held = steps.filter(({ pitch }) => pitch === 7);
const jumped = steps.find(({ pitch }) => pitch < 6);
check(
'Set sound FX parameters with Step holds the pitch for the time, then jumps to the end',
held.length > 0 &&
held.every(({ t }) => t < 2.05) &&
jumped !== undefined &&
jumped.t > 1.95 &&
Math.abs(jumped.pitch + 5) < 0.001,
JSON.stringify(steps),
);
await stop();
// #317: the worklet itself, rendered in an offline context of this browser – what a muted check
// can hear. A second of 440 Hz an octave up is 880 Hz and still a second long; a sweep from
// 0 to -12 over it keeps that second too. Then 16 stereo voices at +5 for 2 s, timed, for
// how many this machine plays at once.
const offline = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, shape) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const tone = new Float32Array(RATE * seconds);
for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.5;
for (let v = 0; v < voices; v++) {
const node = new AudioWorkletNode(context, 'pitch-player', {
numberOfInputs: 0,
outputChannelCount: [channels],
parameterData: { pitch: shape.from },
processorOptions: { file: 'probe', channels: v === 0 ? Array(channels).fill(tone) : undefined, step: 1, loop: false },
});
if (shape.to !== shape.from)
node.parameters.get('pitch').setValueAtTime(shape.from, 0).linearRampToValueAtTime(shape.to, seconds);
node.connect(context.destination);
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, ms: performance.now() - started };
};
const rises = (out, from, to) => {
let count = 0;
for (let i = from + 1; i < to; i++) if (out[i - 1] < 0 && out[i] >= 0) count++;
return count / ((to - from) / RATE);
};
const heard = (out) => {
let last = out.length;
while (last > 0 && Math.abs(out[last - 1]) <= 1e-4) last--;
return last;
};
const up = await render(1, 1, 1, { from: 12, to: 12 });
const sweep = await render(1, 1, 1, { from: 0, to: -12 });
const load = await render(16, 2, 2, { from: 5, to: 5 });
return {
upHz: rises(up.out, 12000, 36000),
upLength: heard(up.out),
sweepEarlyHz: rises(sweep.out, 6000, 12000),
sweepLateHz: rises(sweep.out, 36000, 42000),
sweepLength: heard(sweep.out),
voices: 16,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((16 * 2000) / load.ms),
};
})()`);
check(
'the pitch worklet plays an octave up at the length of the sound, and a sweep down keeps it too',
Math.abs(offline.upHz / 880 - 1) < 0.02 &&
offline.upLength > 48000 - 100 &&
offline.sweepEarlyHz > offline.sweepLateHz * 1.3 &&
offline.sweepLength > 48000 - 100,
JSON.stringify(offline),
);
// #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s,
// feedback 0.5, mix 0.25) comes back left at 0.3 s at half its level, right at 0.6 s at
// half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
const echo = await evaluate(`
(async () => {
const RATE = 48000;
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong });
const render = async (voices, seconds, stage = echoOf(true)) => {
const context = new OfflineAudioContext(2, RATE * seconds, RATE);
const burst = context.createBuffer(1, RATE * 0.01, RATE);
burst.getChannelData(0).fill(0.5);
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = burst;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = await context.startRendering();
return { left: out.getChannelData(0), right: out.getChannelData(1), ms: performance.now() - started };
};
// The middle of the burst: its edges ring a little through the damping.
const peak = (data, at) => {
let sum = 0;
const from = Math.round((at + 0.003) * RATE);
const to = Math.round((at + 0.007) * RATE);
for (let i = from; i < to; i++) sum += Math.abs(data[i]);
return Math.round((sum / (to - from)) * 1000) / 1000;
};
// Samples from where each echo is due to where it is heard, in both kinds of echo.
const late = ({ left, right }) => {
const found = [];
for (let k = 1; k <= 4; k++) {
const from = Math.round((k * 0.3 - 0.05) * RATE);
let at = from;
while (at < from + RATE * 0.1 && Math.abs(left[at]) <= 0.002 && Math.abs(right[at]) <= 0.002) at++;
found.push(at - Math.round(k * 0.3 * RATE));
}
return found;
};
const one = await render(1, 1.3);
const plain = await render(1, 1.3, echoOf(false));
const load = await render(64, 2);
const lateBy = late(one);
const at = (k) => k * 0.3 + lateBy[k - 1] / RATE;
return {
late: lateBy,
latePlain: late(plain),
dry: [peak(one.left, 0), peak(one.right, 0)],
first: [peak(one.left, at(1)), peak(one.right, at(1))],
second: [peak(one.left, at(2)), peak(one.right, at(2))],
third: [peak(one.left, at(3)), peak(one.right, at(3))],
between: [peak(one.left, 0.45), peak(one.right, 0.45)],
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
const near = (value, to) => Math.abs(value - to) < 0.02;
check(
'the player’s ping-pong echo comes back left, then right, then left, each round half as loud',
near(echo.dry[0], 0.5) &&
near(echo.dry[1], 0.5) &&
near(echo.first[0], 0.25) &&
echo.first[1] < 0.005 &&
echo.second[0] < 0.005 &&
near(echo.second[1], 0.125) &&
near(echo.third[0], 0.0625) &&
echo.third[1] < 0.005 &&
echo.between.every((v) => v < 0.005) &&
// Chrome takes a render quantum more for every round of a feedback loop.
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
JSON.stringify(echo),
);
// #322: the Bitcrusher on the worklet, rendered offline. A 440 Hz sine at 0.8 through 2 bits at
// 4 kHz holds every value for 12 samples and leaves only steps of 0.5. 16 bits at 48 kHz
// leave the sound as it is, a mix of 0.5 is half of each. Then 64 stereo voices for 2 s,
// timed, for how many this machine crushes at once.
const crushed = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, stage) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const tone = context.createBuffer(channels, RATE * seconds, RATE);
for (let c = 0; c < channels; c++) {
const data = tone.getChannelData(c);
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.8;
}
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = tone;
const nodes = __gm.sound.stageNodes(context, { kind: 'crush', ...stage });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, tone: tone.getChannelData(0), ms: performance.now() - started };
};
const crush = await render(1, 0.5, 1, { bits: 2, rate: 4000, mix: 1 });
let offStep = 0;
let unheld = 0;
for (let i = 0; i < crush.out.length; i++) {
offStep = Math.max(offStep, Math.abs(crush.out[i] * 2 - Math.round(crush.out[i] * 2)));
if (crush.out[i] !== crush.out[i - (i % 12)]) unheld++;
}
const levels = [...new Set(crush.out)].sort((a, b) => a - b);
const clean = await render(1, 0.5, 1, { bits: 16, rate: 48000, mix: 1 });
const half = await render(1, 0.5, 1, { bits: 1, rate: 48000, mix: 0.5 });
let cleanOff = 0;
let halfOff = 0;
for (let i = 0; i < clean.out.length; i++) {
cleanOff = Math.max(cleanOff, Math.abs(clean.out[i] - clean.tone[i]));
halfOff = Math.max(halfOff, Math.abs(half.out[i] - (half.tone[i] + Math.round(half.tone[i])) / 2));
}
const load = await render(64, 2, 2, { bits: 6, rate: 11025, mix: 0.8 });
return {
levels,
offStep,
unheld,
cleanOff,
halfOff,
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
check(
'the Bitcrusher holds the sound at its rate and rounds it to its bits, and 16 bits at 48 kHz leave it as it is',
JSON.stringify(crushed.levels) === JSON.stringify([-1, -0.5, 0, 0.5, 1]) &&
crushed.offStep < 1e-6 &&
crushed.unheld === 0 &&
crushed.cleanOff < 2 ** -15 &&
crushed.halfOff < 1e-6,
JSON.stringify(crushed),
);
// #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser
// at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second,
// the notch passes it and is two octaves off between, where cos(28°) of it is left. A
// Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples
// at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples
// later. Then 64 stereo voices of each for 2 s, timed.
const swirled = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, stage, fill, at = 0) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(channels, RATE * seconds, RATE);
for (let c = 0; c < channels; c++) fill(sound.getChannelData(c));
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, ms: performance.now() - started };
};
const sine = (data) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8;
};
const rms = (out, from, to) => {
let sum = 0;
for (let i = from; i < to; i++) sum += out[i] ** 2;
return Math.sqrt(sum / (to - from));
};
const phaser = (stages, rate, depth) =>
({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 });
const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out;
const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out;
const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out;
const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200));
const flanger = (offset, sweep) =>
({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 });
const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; };
const heard = (out, from) => {
const found = [];
for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]);
return found;
};
const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE));
const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE));
const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; };
const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise);
const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise);
return {
notch: +rms(notch, 4800, notch.length).toFixed(4),
peak: +rms(peak, 4800, peak.length).toFixed(4),
sweptLow: +Math.min(...windows).toFixed(3),
sweptHigh: +Math.max(...windows).toFixed(3),
still,
sweeping,
phaserVoices: Math.round((64 * 2000) / phaserLoad.ms),
flangerVoices: Math.round((64 * 2000) / flangerLoad.ms),
};
})()`);
check(
'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice',
swirled.notch < 0.01 &&
Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 &&
swirled.sweptLow < 0.1 &&
swirled.sweptHigh > 0.46 &&
swirled.sweptHigh < 0.51 &&
JSON.stringify(swirled.still) ===
JSON.stringify([
[0, 0.5],
[48, 0.5],
]) &&
swirled.sweeping[0]?.[1] === 0.5 &&
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
JSON.stringify(swirled),
);
// #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump
// falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at
// 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out.
const filtered = await evaluate(`
(async () => {
const RATE = 48000;
const level = async (hz, stage) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
const tone = context.createOscillator();
tone.frequency.value = hz;
const half = context.createGain();
half.gain.value = 0.5;
const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage });
tone.connect(half).connect(nodes.input);
nodes.output.connect(context.destination);
tone.start();
const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4);
let sum = 0;
for (const v of out) sum += v * v;
return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2);
};
return {
low12: await level(2000, { filter: 'lowpass', q: -3.0103 }),
low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }),
peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }),
notch: await level(1000, { filter: 'notch', q: 1 }),
};
})()`);
check(
'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out',
Math.abs(filtered.low12 + 12.3) < 0.2 &&
Math.abs(filtered.low48 + 49.2) < 0.8 &&
Math.abs(filtered.peak - 6) < 0.1 &&
filtered.notch < -40,
JSON.stringify(filtered),
);
// #319: Pan rendered offline. A mono sine at 0.5 all to the left leaves the right silent, in the
// middle each side gets 0.707 of it. A stereo sound panned half right keeps its right side
// and moves part of its left into it, as Web Audio pans stereo: left 0.707, right 1 + 0.707.
const panned = await evaluate(`
(async () => {
const RATE = 48000;
const peaks = async (pan, channels) => {
const context = new OfflineAudioContext(2, RATE / 10, RATE);
const sound = context.createBuffer(channels, RATE / 10, RATE);
for (let c = 0; c < channels; c++) {
const data = sound.getChannelData(c);
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.5;
}
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'pan', pan });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
const out = await context.startRendering();
return [0, 1].map((c) => +Math.max(...out.getChannelData(c).map(Math.abs)).toFixed(3));
};
return { left: await peaks(-1, 1), middle: await peaks(0, 1), balance: await peaks(0.5, 2) };
})()`);
check(
'Pan moves a mono sound between the sides, and a stereo one towards the side it goes to',
JSON.stringify(panned) ===
JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }),
JSON.stringify(panned),
);
// #326: Compressor and Limiter rendered offline. An impulse comes out of the Compressor 288
// samples late (the spec's 6 ms) and out of the Limiter 240 (5 ms). A 1 kHz sine at full
// scale through a hard-knee Compressor at -12 dB, 4:1 with full auto-gain comes back near
// full scale; with no auto-gain and no makeup it is 9 dB down. A sine at 2 through a
// Limiter at -6 dB never goes past it, with auto-gain 1 it sits at the ceiling of -1 dB.
const dynamics = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(1, RATE / 2, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const impulse = (data) => { data[480] = 0.01; };
const sine = (amplitude) => (data) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * amplitude;
};
const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; };
const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2);
const compress = (gain) => ({ kind: 'compress', threshold: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain });
const makeup = (autoGain) => __gm.sound.compressorMakeup({ threshold: -24, knee: 0, ratio: 4, makeup: 0, autoGain });
const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain });
const limited = await render(limit(0), sine(2));
// A sine at the -18 dB auto-gain keeps as loud as it came in (#378).
const reference = sine(10 ** (-18 / 20));
return {
compressorLate: loudest(await render(compress(1), impulse)) - 480,
limiterLate: loudest(await render(limit(0), impulse)) - 480,
autoGain: db(await render(compress(makeup(true)), reference)),
noMakeup: db(await render(compress(makeup(false)), reference)),
limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3),
ceiling: db(await render(limit(1), sine(2))),
};
})()`);
check(
'a Compressor is 6 ms late and auto-gain keeps -18 dB as loud, a Limiter is 5 ms late and never passes its limit',
dynamics.compressorLate === 288 &&
dynamics.limiterLate === 240 &&
Math.abs(dynamics.autoGain + 18) < 0.5 &&
Math.abs(dynamics.noMakeup + 22.5) < 0.5 &&
dynamics.limitedPeak <= -6 + 1e-3 &&
dynamics.ceiling <= -1 + 1e-3 &&
dynamics.ceiling > -1.2,
JSON.stringify(dynamics),
);
// #323: Saturation rendered offline. An impulse through it comes out 192 samples late at 48 and
// at 44.1 kHz, the oversampling's delay. Half bent and half dry at 1125 Hz – where 192
// samples off would cancel – the two meet in step and the sine stays as loud. A sine at
// -12 dBFS driven 24 dB into tanh comes back as loud with full auto-gain.
const saturated = await evaluate(`
(async () => {
const render = async (rate, stage, fill) => {
const context = new OfflineAudioContext(1, rate / 2, rate);
const sound = context.createBuffer(1, rate / 2, rate);
fill(sound.getChannelData(0), rate);
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'saturate', curve: 'hard', drive: 0, tone: 20000, level: 1, mix: 1, ...stage });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const impulse = (data) => { data[480] = 0.5; };
const sine = (hz, amplitude) => (data, rate) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * hz * i) / rate) * amplitude;
};
const loudest = (out) => out.reduce((at, v, i) => (Math.abs(v) > Math.abs(out[at]) ? i : at), 0);
const db = (out, amplitude) => {
let sum = 0;
const from = out.length / 2;
for (let i = from; i < out.length; i++) sum += out[i] ** 2;
return +(20 * Math.log10(Math.sqrt(sum / (out.length - from)) / (amplitude * Math.SQRT1_2))).toFixed(2);
};
return {
late48: loudest(await render(48000, {}, impulse)) - 480,
late44: loudest(await render(44100, {}, impulse)) - 480,
halfDry: db(await render(48000, { mix: 0.5 }, sine(1125, 0.5)), 0.5),
autoGain: db(await render(48000, { curve: 'soft', drive: 24, level: 10 ** (__gm.sound.saturationAutoGain('soft', 24) / 20) }, sine(1000, 0.25)), 0.25),
};
})()`);
check(
'a Saturation is 192 samples late at either rate, its dry half meets the bent one in step, auto-gain keeps it as loud',
saturated.late48 === 192 &&
saturated.late44 === 192 &&
Math.abs(saturated.halfDry) < 0.1 &&
Math.abs(saturated.autoGain) < 0.5,
JSON.stringify(saturated),
);
// #320: Tremolo and Vibrato on the worklet, rendered offline. A Tremolo square at 10 Hz, depth 1,
// on a steady 0.5 for a voice starting at 0.1 s: silence before it, then 0.5 for 50 ms and
// 0 for 50 ms. A Vibrato square at 5 Hz, one semitone deep, on a 1 kHz sine: the first
// half of a period at 1059.5 Hz, the second at 940.5 Hz.
const swung = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill, at = 0) => {
const context = new OfflineAudioContext(1, RATE, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(1, RATE, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE); };
const levels = (out, from, to) => [...new Set(out.subarray(from, to))].map((v) => +v.toFixed(3));
const tremolo = await render({ kind: 'tremolo', shape: 'square', rate: 10, depth: 1 }, steady, 0.1);
const hz = (out, from, to) => {
const at = [];
for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++)
if (out[i - 1] < 0 && out[i] >= 0) at.push(i - out[i] / (out[i] - out[i - 1]));
return +(((at.length - 1) * RATE) / (at.at(-1) - at[0])).toFixed(1);
};
const vibrato = await render({ kind: 'vibrato', shape: 'square', rate: 5, depth: 1 }, sine);
return {
before: levels(tremolo, 0, 4800),
on: levels(tremolo, 4810, 7190),
off: levels(tremolo, 7210, 9590),
up: hz(vibrato, 0.41, 0.49),
down: hz(vibrato, 0.51, 0.59),
};
})()`);
check(
'a Tremolo starts its swing with its voice, a Vibrato jumps a semitone up and as far down',
JSON.stringify([swung.before, swung.on, swung.off]) === JSON.stringify([[0], [0.5], [0]]) &&
Math.abs(swung.up - 1059.5) < 1 &&
Math.abs(swung.down - 940.5) < 1,
JSON.stringify(swung),
);
// #328: Reverb rendered offline. An impulse at 0.05 s through a Custom reverb of 1 s with a
// pre-delay of 50 ms: nothing of it before 0.1 s, then it falls by 60 dB a second, 36 dB
// from 0.2-0.3 s to 0.8-0.9 s after it. At mix 0.5 the impulse itself is there at half.
// With a sound as its impulse response – one sample at 0.2 s – the impulse comes back
// 0.2 s and the pre-delay later, and nowhere else.
const reverbed = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, impulse) => {
const context = new OfflineAudioContext(1, RATE * 1.5, RATE);
const sound = context.createBuffer(1, RATE * 1.5, RATE);
sound.getChannelData(0)[RATE * 0.05] = 0.5;
let ir;
if (impulse) {
ir = context.createBuffer(2, RATE / 2, RATE);
for (let c = 0; c < 2; c++) ir.getChannelData(c)[RATE * impulse] = 1;
}
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'reverb', decay: 1, damping: 0, predelay: 0.05, mix: 1, ...stage }, 0, ir);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const db = (out, from, to) => {
let sum = 0;
for (let i = from * RATE; i < to * RATE; i++) sum += out[i] ** 2;
return 10 * Math.log10(sum / ((to - from) * RATE));
};
const peak = (out, from, to) => Math.max(...out.subarray(from * RATE, to * RATE).map(Math.abs));
const wet = await render({});
const half = await render({ mix: 0.5 });
const sounded = await render({ impulse: 'room.wav' }, 0.2);
const heard = [];
sounded.forEach((v, i) => { if (Math.abs(v) > 1e-6) heard.push(i); });
return {
before: peak(wet, 0, 0.0999),
fall: +(db(wet, 0.25, 0.35) - db(wet, 0.85, 0.95)).toFixed(1),
dry: +half[RATE * 0.05].toFixed(4),
sound: [heard[0], heard.at(-1)],
};
})()`);
check(
'a Reverb waits its pre-delay, falls 60 dB in its decay, and takes a sound as its impulse response',
reverbed.before === 0 &&
Math.abs(reverbed.fall - 36) < 3 &&
reverbed.dry === 0.25 &&
JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]),
JSON.stringify(reverbed),
);
// #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of
// 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75
// the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended
// half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it
// came in is held back to meet it.
const wetted = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
const sound = context.createBuffer(1, RATE / 2, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; };
const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3);
const delay = async (mix) => {
const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady);
return [at(out, 0.05), at(out, 0.2)];
};
const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 };
const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine);
let sum = 0;
for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2;
return {
gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1),
quarter: await delay(0.25),
threeQuarters: await delay(0.75),
echoOnly: await delay(1),
compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2),
};
})()`);
check(
'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step',
wetted.gain === 0.375 &&
JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) ===
JSON.stringify([
[0.5, 0.75],
[0.25, 0.75],
[0, 0.5],
]) &&
Math.abs(wetted.compressed) < 0.1,
JSON.stringify(wetted),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
// 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed.
const chipped = await evaluate(`
(async () => {
const RATE = 48000;
const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0,
vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 };
const render = async (voices, seconds, params, options) => {
const context = new OfflineAudioContext(1, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const said = [];
for (let v = 0; v < voices; v++) {
const node = new AudioWorkletNode(context, 'chip-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: params,
processorOptions: { ...shape, ...options },
});
node.port.onmessage = (event) => said.push(event.data);
node.connect(context.destination);
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
const ms = performance.now() - started;
await new Promise((resolve) => setTimeout(resolve, 100));
return { out, ms, said };
};
const cycles = (out, from, to) => {
let n = 0;
for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++;
return n;
};
const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 });
const heard = tone.out.subarray(0.25 * RATE);
const firstHeard = tone.out.findIndex((v) => v !== 0);
const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 });
const lastHeard = slid.out.findLastIndex((v) => v !== 0);
const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 });
return {
firstHeard,
periods: cycles(tone.out, 0.25, 1),
up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000,
peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0),
lastHeard: lastHeard / RATE,
said: slid.said,
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
check(
'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency',
chipped.firstHeard === 0.25 * 48000 &&
Math.abs(chipped.periods - 330) <= 1 &&
Math.abs(chipped.up - 0.25) < 0.01 &&
Math.abs(chipped.peak - 0.5) < 0.01 &&
Math.abs(chipped.lastHeard - 1) < 0.01 &&
chipped.said.join() === 'ended',
JSON.stringify(chipped),
);
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
const atCenter = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (mix, glide) => {
const context = new OfflineAudioContext(2, RATE, RATE);
const merger = context.createChannelMerger(2);
merger.connect(context.destination);
const shareA = context.createGain();
const shareB = context.createGain();
shareA.gain.value = 0;
shareB.gain.value = 0;
const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB);
for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) {
const steady = context.createConstantSource();
steady.offset.value = level;
steady.connect(share).connect(merger, 0, side);
steady.start();
}
if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1);
driver.start();
const out = await context.startRendering();
const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2];
return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5);
};
const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000);
const fixed = {};
for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false));
return { fixed, glide: (await render(0, true)).map(round) };
})()`);
const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b);
check(
'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides',
pairNear(atCenter.fixed[0], 1, 0) &&
pairNear(atCenter.fixed[0.25], 1, 0.5) &&
pairNear(atCenter.fixed[0.5], 1, 1) &&
pairNear(atCenter.fixed[0.75], 0.5, 1) &&
pairNear(atCenter.fixed[1], 0, 1) &&
pairNear(atCenter.glide[0], 1, 0.5) &&
pairNear(atCenter.glide[1], 1, 1) &&
pairNear(atCenter.glide[2], 0.5, 1),
JSON.stringify(atCenter),
);
await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
logic.variables = [];
const graph = logic.graph;
graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id));
graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp');
graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp');
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' });
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);
// 34g-5. An instrument (#331): "+ → Instrument" opens its tab on Notes → Sample → Output, which
// plays nothing until the Sample has a sound. With the blip, looped, every key of C4–B4
// sounds; a key held by the pointer plays until it is let go, the computer keyboard plays
// as a tracker does, and a game's `Play note` releases its note after its duration.
await showTab('scene');
await clickText('+');
await pickAssetType(t, 'Instrument');
const insCreated = await json(`{
tab: __gm.tab(),
current: __gm.instrument.current(),
info: __gm.instrument.info(),
keys: __gm.instrument.keys(),
assets: __gm.state.project.assets.filter((a) => a.type === 'instrument')
.map((a) => ({ id: a.id, nodes: a.graph.nodes.map((n) => n.type) })),
}`);
check(
'the + menu creates an instrument, Notes → Sample → Output, and opens its tab; no key sounds yet',
insCreated.tab === 'instrument' &&
insCreated.assets.length === 1 &&
insCreated.current === insCreated.assets[0].id &&
insCreated.assets[0].nodes.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput' &&
insCreated.info === 'No sound picked' &&
insCreated.keys.sounding.length === 0,
JSON.stringify(insCreated),
);
const insId = insCreated.assets[0]?.id;
// Bottom left: the fitted graph is one row of three nodes, its links among them.
const insCanvas = await json('__gm.instrument.canvasRect()');
await click(insCanvas.x + 30, insCanvas.y + insCanvas.h - 30, 'right');
const insPalette = await json('__gm.overlayTexts()');
check(
'its palette offers its own nodes and the effects, but no Input and no behavior node',
['Notes', 'Sample', 'Parameters', 'Sound FX', 'Envelope', 'Gain', 'Filter', 'Delay'].every((label) =>
insPalette.includes(label),
) &&
!insPalette.includes('Input') &&
!insPalette.includes('Branch'),
JSON.stringify(insPalette.slice(0, 14)),
);
await press('Escape');
await mutate(`
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, {
sound: ${JSON.stringify(soundId)},
play: 'loop',
});`);
const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }');
check(
'with a sound picked the twelve keys from C4 to B4 sound, the others stay grey',
insReady.info === '12 notes' &&
insReady.keys.sounding.join() === [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71].join(),
JSON.stringify(insReady),
);
await shot('34-instrument');
await evaluate('__gm.sound.stopAll()');
const beforeKey = await stats();
const e4 = await json('__gm.instrument.keyAt(64)');
await t.mouse('mouseMoved', e4.x, e4.y);
await t.mouse('mousePressed', e4.x, e4.y);
const heldKey = await waitFor(stats, (s) => s.started > beforeKey.started);
// Longer than the blip: a looped note that ended with its sound would be gone by now.
await sleep(600);
const stillHeld = await json('{ stats: __gm.sound.stats(), keys: __gm.instrument.keys() }');
check(
'holding E4 plays the blip four semitones up, looped, as one layer of the instrument, and lights the key',
heldKey.last?.file === `instrument:${insId}` &&
heldKey.last.pitches.join() === '4' &&
heldKey.last.layers === 1 &&
stillHeld.stats.playing === 1 &&
stillHeld.keys.held.join() === '64',
JSON.stringify({ heldKey, stillHeld }),
);
await t.mouse('mouseReleased', e4.x, e4.y);
const letGo = await waitFor(stats, (s) => s.playing === 0);
check(
'letting go of the key ends the note: its sample fades out and the voice goes',
letGo.playing === 0 && (await json('__gm.instrument.keys()')).held.length === 0,
JSON.stringify(letGo),
);
// "Loop, then release": held, it loops 0.05 to 0.1 s; let go, it plays the 0.15 s after the loop.
const setPlay = (fields) =>
mutate(`
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, ${JSON.stringify(fields)});`);
await setPlay({ play: 'release', loopStart: 0.05, loopEnd: 0.1 });
const beforeRelease = await stats();
await t.mouse('mousePressed', e4.x, e4.y);
await waitFor(stats, (s) => s.started > beforeRelease.started);
await sleep(600);
const heldLoop = await stats();
await t.mouse('mouseReleased', e4.x, e4.y);
const leftAt = await audioNow();
const ringingKeys = await json('__gm.instrument.keys()');
const playedOut = await waitFor(audioNow, (s) => s.playing === 0);
const afterKeys = await waitFor(
() => json('__gm.instrument.keys()'),
(keys) => keys.ringing.length === 0,
);
// 0.15 s after the loop, up to two rounds more (the one it is in, the one the worklet read into)
// and the 150 ms waitFor polls at: a fade would be gone in 0.01 s.
const outFor = playedOut.clock - leftAt.clock;
check(
'"Loop, then release" loops while held, and let go plays on past the loop with its key half lit',
heldLoop.playing === 1 &&
ringingKeys.held.length === 0 &&
ringingKeys.ringing.join() === '64' &&
outFor > 0.12 &&
outFor < 0.5 &&
afterKeys.ringing.length === 0,
JSON.stringify({ heldLoop: heldLoop.playing, ringingKeys, outFor, afterKeys }),
);
await setPlay({ play: 'loop', loopStart: 0, loopEnd: 0 });
// The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4.
const beforeZ = await stats();
await press('z');
await sleep(200);
const lowZ = await stats();
await press('ArrowRight');
await t.keyDown('z');
const typed = await waitFor(stats, (s) => s.started > lowZ.started);
const typedKeys = await json('__gm.instrument.keys()');
await t.keyUp('z');
const typedOff = await waitFor(stats, (s) => s.playing === 0);
check(
'the computer keyboard plays as a tracker: Z is C3 and silent here, one octave up it is C4 until let go',
lowZ.started === beforeZ.started &&
typedKeys.octave === 4 &&
typedKeys.held.join() === '60' &&
typed.last?.pitches.join() === '0' &&
typedOff.playing === 0,
JSON.stringify({ beforeZ: beforeZ.started, lowZ: lowZ.started, typed, typedKeys, typedOff }),
);
await press('ArrowLeft');
// Twice the eleven keys off the root, all held at once: each plays through the pitch worklet, and
// the ten oldest are cut, so no more than twelve phase vocoders run and the audio thread keeps up.
await evaluate('__gm.sound.stopAll()');
const crowd = await json(`(() => {
const voices = [...Array(22)].map((_, i) => __gm.instrument.play(61 + (i % 11)));
return { started: voices.filter((v) => v > 0).length, playing: voices.map((v) => __gm.sound.playing(v)), stats: __gm.sound.stats() };
})()`);
check(
'held at once, 22 notes off the root keep only the 12 newest on the pitch worklet',
crowd.started === 22 &&
crowd.playing.join() === [...Array(22)].map((_, i) => i >= 10).join() &&
crowd.stats.vocoders === 12 &&
crowd.stats.playing === 12,
JSON.stringify({ started: crowd.started, playing: crowd.playing, vocoders: crowd.stats.vocoders }),
);
// Closing the tab ends what it plays: E4, looped and held by no key, falls silent with it.
await evaluate('__gm.sound.stopAll()');
await json('__gm.instrument.play(64)');
const heldBeforeClose = await stats();
await evaluate(`__gm.closeDocument('asset:${insId}')`);
await sleep(300);
const afterClose = await stats();
await evaluate(`__gm.openDocument('asset:${insId}')`);
check(
'closing the Instrument tab ends a note it still plays',
heldBeforeClose.playing === 1 && afterClose.playing === 0,
JSON.stringify({ held: heldBeforeClose.playing, closed: afterClose.playing }),
);
// 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and
// Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change
// only plays with Audition on. Three undos bring the blip back.
const insGraph = () =>
json(`(() => {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null;
return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') };
})()`);
const clickHook = async (hook) => {
const at = await json(`__gm.instrument.${hook}()`);
await click(at.x, at.y);
};
await evaluate('__gm.sound.stopAll()');
const beforePreset = await stats();
await clickHook('presetAt');
const presetMenu = await json('__gm.overlayTexts()');
await clickText('Laser');
await sleep(400);
const preset = await stats();
const laser = await insGraph();
const presetInfo = await json('__gm.instrument.info()');
check(
'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays nothing',
['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) =>
presetMenu.includes(label),
) &&
laser.types.join() ===
'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' &&
laser.chip?.followNote === false &&
laser.chip.slide < 0 &&
laser.envelope?.sustain === 0 &&
preset.started === beforePreset.started &&
presetInfo === '128 notes',
JSON.stringify({ presetMenu, laser, started: [beforePreset.started, preset.started], presetInfo }),
);
await shot('34-instrument-preset');
// #386: the Laser's Chip is drawn as a device between its ports: a button per wave, the "follow
// note" lever and a knob per number, each change one undo step, and its screens drawn from
// it. A Laser falls: its pitch ends lower than it starts. Three undos bring the Laser back.
await clickText('Fit');
await sleep(300);
const chipId = (
await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentChip')`,
)
).id;
const chipPanel = () => json(`__gm.chip.panel('${chipId}')`);
const chipShown = await chipPanel();
const chipRect = chipShown.rect;
const chipBeside = (p) => p.y > chipRect.y && p.y < chipRect.y + chipRect.h;
await click(chipShown.waves.triangle.x, chipShown.waves.triangle.y);
const chipPicked = { params: (await insGraph()).chip, panel: await chipPanel() };
await click(chipShown.follow.at.x, chipShown.follow.at.y);
const chipFollowed = { params: (await insGraph()).chip, panel: await chipPanel() };
const slideDial = chipShown.knobs.slide.dial;
await drag(slideDial.x, slideDial.y, slideDial.x, slideDial.y - 20);
const chipSlid = (await insGraph()).chip;
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
const chipUndone = (await insGraph()).chip;
check(
'a Chip node shows its device between its ports; a wave button, the lever and a knob each change it as one undo step, and its screens follow',
chipShown.shown &&
chipShown.size.w === 840 &&
chipShown.size.h === 280 &&
Object.values(chipShown.inputs).length === 12 &&
Object.values(chipShown.inputs).every((p) => p.x < chipRect.x && chipBeside(p)) &&
Object.values(chipShown.outputs).every((p) => p.x > chipRect.x + chipRect.w && chipBeside(p)) &&
chipShown.wave === laser.chip.wave &&
chipShown.follow.on === false &&
chipShown.knobs.slide.value === laser.chip.slide &&
chipShown.pitch.from.startsWith(chipShown.knobs.frequency.number) &&
chipShown.pitch.last < 0 &&
chipPicked.params.wave === 'triangle' &&
chipPicked.panel.waveScreen === 'TRIANGLE' &&
chipPicked.panel.idle.includes('duty') &&
chipFollowed.params.followNote === true &&
chipFollowed.panel.follow.on &&
chipFollowed.panel.idle.includes('frequency') &&
// A Laser's "min frequency" may lie above A4: then it says the note ends at once.
chipFollowed.panel.pitch.from.startsWith('A4 440 Hz') &&
chipSlid.slide > laser.chip.slide &&
JSON.stringify(chipUndone) === JSON.stringify(laser.chip),
JSON.stringify({
chipShown,
chipPicked,
chipFollowed,
slide: [laser.chip.slide, chipSlid.slide],
chipUndone,
}),
);
// #388: the Laser's Envelope is drawn as a device between its ports, its curve on a screen. Its
// sustain is 0, so the curve ends with the decay; nothing takes its value, so "from" and
// "to" are grey. A knob and a scrubbed field each change it as one undo step.
const envelopeId = (
await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentEnvelope')`,
)
).id;
const envelopePanel = () => json(`__gm.envelope.panel('${envelopeId}')`);
const envShown = await envelopePanel();
const envRect = envShown.rect;
const envBeside = (p) => p.y > envRect.y && p.y < envRect.y + envRect.h;
const decayDial = envShown.knobs.decay.dial;
await drag(decayDial.x, decayDial.y, decayDial.x, decayDial.y - 20);
const envTurned = { params: (await insGraph()).envelope, panel: await envelopePanel() };
const toField = envShown.fields.to.at;
await drag(toField.x, toField.y, toField.x + 30, toField.y);
const envScrubbed = { params: (await insGraph()).envelope, panel: await envelopePanel() };
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
const envUndone = (await insGraph()).envelope;
check(
'an Envelope node shows its device between its ports with its curve; a knob and a field each change it as one undo step',
envShown.shown &&
envShown.size.w === 520 &&
envShown.size.h === 280 &&
Object.values(envShown.inputs).length === 7 &&
Object.values(envShown.inputs).every((p) => p.x < envRect.x && envBeside(p)) &&
Object.values(envShown.outputs).every((p) => p.x > envRect.x + envRect.w && envBeside(p)) &&
envShown.trace.ends &&
envShown.trace.stages.map((s) => s.key).join() === 'attack,decay' &&
envShown.screen.ends === 'NOTE ENDS' &&
envShown.range === '' &&
envShown.idle.includes('from') &&
envShown.idle.includes('to') &&
envShown.knobs.decay.value === laser.envelope.decay &&
envTurned.params.decay > laser.envelope.decay &&
envTurned.panel.knobs.decay.value === envTurned.params.decay &&
envTurned.panel.trace.stages[1].seconds === envTurned.params.decay &&
envScrubbed.params.to > 1 &&
envScrubbed.panel.fields.to.text === String(envScrubbed.params.to) &&
JSON.stringify(envUndone) === JSON.stringify(laser.envelope),
JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }),
);
const beforeRandom = await stats();
await clickHook('randomizeAt');
const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);
const drawn = await insGraph();
await clickHook('mutateAt');
const mutated = await waitFor(stats, (s) => s.started > randomized.started);
const moved = await insGraph();
check(
'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once',
randomized.started > beforeRandom.started &&
drawn.chip?.wave === laser.chip.wave &&
drawn.chip.frequency !== laser.chip.frequency &&
mutated.started > randomized.started &&
JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]),
JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }),
);
const setFrequency = (hz) =>
mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)})
.graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`);
await sleep(300);
const silent = await stats();
await setFrequency(300);
await sleep(500);
const unheard = await stats();
await clickHook('auditionAt');
const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled;
await setFrequency(400);
const auditioned = await waitFor(stats, (s) => s.started > unheard.started);
await clickHook('auditionAt');
check(
'a change to the instrument is heard only with Audition on',
unheard.started === silent.started && auditionOn && auditioned.started > unheard.started,
JSON.stringify({
silent: silent.started,
unheard: unheard.started,
auditionOn,
auditioned: auditioned.started,
}),
);
for (let i = 0; i < 3; i++) await t.clickToolbar('undo');
const restored = await insGraph();
check(
'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again',
restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify(restored.types),
);
// 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and
// plays nothing. One undo takes it back.
await evaluate('__gm.sound.stopAll()');
const beforeLead = await stats();
await clickHook('presetAt');
await clickText('Square Lead');
await sleep(400);
const lead = await stats();
const leadGraph = await insGraph();
const leadInfo = await json('__gm.instrument.info()');
await t.clickToolbar('undo');
const unled = await insGraph();
check(
'Preset → Square Lead plays every note at its own pitch, holds it and plays nothing; one undo takes it back',
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
leadGraph.chip?.followNote === true &&
leadGraph.envelope?.sustain > 0 &&
lead.started === beforeLead.started &&
leadInfo === '128 notes' &&
unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify({
chip: leadGraph.chip,
envelope: leadGraph.envelope,
started: [beforeLead.started, lead.started],
leadInfo,
unled,
}),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify(
node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }),
)});
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`);
// A key still held in the Instrument tab is not part of the game: Play ends it.
const preview = await json('__gm.instrument.play(64)');
const previewHeld = await json(`__gm.sound.playing(${preview})`);
await showTab('scene');
const beforeNote = await stats();
await play();
const previewAfterPlay = await json(`__gm.sound.playing(${preview})`);
check(
'Play ends what the tabs play: a note held in the Instrument tab is gone',
preview > 0 && previewHeld && !previewAfterPlay,
JSON.stringify({ preview, previewHeld, previewAfterPlay }),
);
// The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard
// well past the 0.25 s of the blip, never past its duration, and is gone soon after.
const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })');
const noted = await waitFor(noteNow, (s) => s.stats.started > beforeNote.started);
const heard = await trace(noteNow, (s) => s.stats.playing === 0);
const noteAt = noted.stats.last?.at ?? 0;
const heardAt = heard.filter((s) => s.stats.playing === 1).map((s) => +(s.clock - noteAt).toFixed(3));
const goneAt = +(heard.at(-1).clock - noteAt).toFixed(3);
check(
'a game’s Play note plays G4 – the blip seven semitones up – and lets it go after its duration',
noted.stats.last?.file === `instrument:${insId}` &&
noted.stats.last.pitches.join() === '7' &&
heardAt.some((t) => t > 0.5) &&
heardAt.every((t) => t < 1.6) &&
heard.at(-1).stats.playing === 0 &&
goneAt < 3,
JSON.stringify({ last: noted.stats.last, heardAt, goneAt }),
);
await stop();
// #332: the scheduler, driven by the hook – nothing in the editor plays a sequence yet. A bar of
// quarters at 150 BPM in a loop: every note is planned before its second comes, each 0.4 s
// after the one before, also across the seam into the next round. Where the music stands
// is the audio clock less the output latency, counted from the first note.
// #366: the audio clock moves on by a render block even between two reads in one expression, so
// it is read before and after the position, and the position lies between the two.
const bar = [60, 62, 64, 65].map((pitch, i) => ({
tick: i * 480,
length: 240,
note: pitch,
velocity: 1,
instrument: insId,
}));
await evaluate(
`__gm.music.play(${JSON.stringify({ notes: bar })}, { tempo: 150, loop: { start: 0, end: 1920 } })`,
);
const planned = await waitFor(
() => json('__gm.music.planned()'),
(p) => p.length >= 9,
40,
);
const where = await json(
`({
before: __gm.sound.clock(),
position: __gm.music.position(),
after: __gm.sound.clock(),
latency: __gm.music.latency(),
})`,
);
await evaluate('__gm.music.stop()');
const musicOff = await waitFor(stats, (s) => s.playing === 0);
const apart = planned.slice(1, 9).map((p, i) => +(p.at - planned[i].at).toFixed(6));
const ahead = planned.map((p) => +(p.at - p.planned).toFixed(3));
const ticksHeardAt = (clock) => ((clock - where.latency - planned[0].at) * 150 * 480) / 60;
const heardTicks = [ticksHeardAt(where.before), ticksHeardAt(where.after)];
// How far the position lies past the first read, around the loop of 1920 ticks.
const pastBefore = (((where.position.ticks - heardTicks[0]) % 1920) + 1920) % 1920;
check(
'the scheduler plans a looped bar on the audio clock: every note ahead of its second, 0.4 s apart across the seam',
planned.length >= 9 &&
planned
.slice(0, 9)
.map((p) => p.note)
.join() === '60,62,64,65,60,62,64,65,60' &&
planned.every((p) => p.voice > 0 && Math.abs(p.duration - 0.2) < 1e-9 && p.tempo === 150) &&
apart.every((gap) => Math.abs(gap - 0.4) < 1e-6) &&
ahead.every((s) => s > 0 && s <= 0.16) &&
(pastBefore <= heardTicks[1] - heardTicks[0] + 1 || pastBefore > 1919) &&
where.position.bar === 1 &&
musicOff.playing === 0 &&
(await json('__gm.music.playing()')) === false,
JSON.stringify({ apart, ahead, where, heardTicks, pastBefore, playing: musicOff.playing }),
);
// A note planned ahead through the pitch worklet starts on its sample, also inside a render block:
// rendered offline at pitch 0, which plays the sound as it is, so the first sample heard is it.
const onsets = await evaluate(`
(async () => {
const RATE = 48000;
const onset = async (startAt) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const node = new AudioWorkletNode(context, 'pitch-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: { pitch: 0 },
processorOptions: { file: 'onset', channels: [new Float32Array(RATE).fill(0.5)], step: 1, loop: false, startAt },
});
node.connect(context.destination);
const out = (await context.startRendering()).getChannelData(0);
return out.findIndex((v) => Math.abs(v) > 1e-6);
};
const at = [0.0123, 0.25, 0.05 + 0.4 * 2 / 3];
return { expected: at.map((s) => Math.round(s * RATE)), heard: await Promise.all(at.map(onset)) };
})()`);
check(
'a pitched note planned ahead starts on its very sample, inside a render block too',
onsets.heard.join() === onsets.expected.join() && onsets.expected.some((i) => i % 128 !== 0),
JSON.stringify(onsets),
);
// #335: an Envelope's curve as Chrome renders it – up in the attack, easing towards the sustain,
// and from wherever it is when the note ends down to silence in the release, mid-attack too –
// and a Gain it moves through a WaveShaper, from a quarter to full in equal ratios.
const curves = await evaluate(`
(async () => {
const RATE = 48000;
const envelope = { id: 'e', attack: 0.1, decay: 0.2, sustain: 0.5, release: 0.3 };
const render = async (releaseAt, moved) => {
const context = new OfflineAudioContext(1, RATE, RATE);
const driver = __gm.sound.envelopeDriver(context, envelope, 0);
if (releaseAt !== null) __gm.sound.releaseEnvelope(driver.offset, envelope, 0, releaseAt);
if (moved) {
const steady = context.createConstantSource();
const stage = { kind: 'gain', gain: 1, mod: { envelope, from: 0.25, to: 1 } };
const made = __gm.sound.stageWith(context, stage, driver);
steady.connect(made.input);
made.output.connect(context.destination);
steady.start();
} else driver.connect(context.destination);
driver.start();
const out = (await context.startRendering()).getChannelData(0);
return (s) => Math.round(out[Math.round(s * RATE)] * 10000) / 10000;
};
const held = await render(0.5, false);
const early = await render(0.05, false);
const gain = await render(null, true);
return {
held: [0.05, 0.1, 0.3, 0.5, 0.65, 0.8].map(held),
early: [0.05, 0.15, 0.35].map(early),
gain: [0.05, 0.1, 0.9].map(gain),
};
})()`);
const closeTo = (values, expected) => values.every((v, i) => Math.abs(v - expected[i]) < 0.002);
check(
'an Envelope rises, eases to its sustain and releases from where it is, and moves a Gain with that curve',
closeTo(curves.held, [0.5, 1, 0.5005, 0.5, 0.0158, 0.0005]) &&
closeTo(curves.early, [0.5, 0.05, 0.0005]) &&
closeTo(curves.gain, [0.5, 1, 0.5]),
JSON.stringify(curves),
);
// A note of 0.2 s through an Envelope with a release of 1 s: the looped blip plays on through the
// release, and the voice goes with it – without one it would end 10 ms after the note.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const into = graph.edges.find((e) => e.to.node === graph.nodes.find((n) => n.type === 'InstrumentOutput').id);
graph.nodes.push(${JSON.stringify(node('ins-env', 'InstrumentEnvelope', 300, 120, { release: 1 }))});
graph.edges.push({ id: 'ins-e-env', from: { node: 'ins-env', port: 'out' }, to: { ...into.to } });
into.to = { node: 'ins-env', port: 'in' };`);
await evaluate('__gm.sound.stopAll()');
const beforeShaped = await stats();
await evaluate(
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
);
const shaped = await waitFor(noteNow, (s) => s.stats.started > beforeShaped.started);
const ringOut = await trace(noteNow, (s) => s.stats.playing === 0);
await evaluate('__gm.music.stop()');
const shapedAt = shaped.stats.last?.at ?? 0;
const shapedGone = +(ringOut.at(-1).clock - shapedAt).toFixed(3);
// 0.2 s of note and 1 s of release; up to the 150 ms waitFor polls at on top.
check(
'a note through an Envelope plays on through its release and goes with it',
shaped.stats.last?.envelopes === 1 &&
ringOut.some((s) => s.stats.playing === 1 && s.clock - shapedAt > 0.8) &&
ringOut.at(-1).stats.playing === 0 &&
shapedGone > 1.15 &&
shapedGone < 1.6,
JSON.stringify({ last: shaped.stats.last, shapedGone }),
);
// #329: the Sample swapped for a Chip, the Envelope's sustain at 0 and its decay 0.1 s. The note of
// 0.2 s is over once the decay is, on its own – held to its end and through the release of
// 1 s it would take 1.2 s.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes');
graph.nodes.push(${JSON.stringify(node('ins-chip', 'InstrumentChip', 120, 200, { wave: 'saw', slide: -12 }))});
graph.edges.find((e) => e.to.node === 'ins-env' && e.to.port === 'in').from = { node: 'ins-chip', port: 'out' };
graph.edges.push({ id: 'ins-e-chip', from: { node: notes.id, port: 'any' }, to: { node: 'ins-chip', port: 'trigger' } });
Object.assign(graph.nodes.find((n) => n.id === 'ins-env').params, { attack: 0.005, decay: 0.1, sustain: 0 });`);
const beforeChip = await stats();
await evaluate(
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
);
const chipNote = await waitFor(noteNow, (s) => s.stats.started > beforeChip.started);
const chipOut = await trace(noteNow, (s) => s.stats.playing === 0);
await evaluate('__gm.music.stop()');
const chipGone = +(chipOut.at(-1).clock - (chipNote.stats.last?.at ?? 0)).toFixed(3);
// 0.105 s of attack and decay; up to the 150 ms the polls take on top.
check(
'a Chip plays a note without a sound file, and an Envelope with sustain 0 ends it after its decay',
chipNote.stats.worklet === true &&
chipNote.stats.last?.file === `instrument:${insId}` &&
chipNote.stats.last?.envelopes === 1 &&
chipNote.stats.last?.duration === 0 &&
chipOut.at(-1).stats.playing === 0 &&
chipGone < 0.5,
JSON.stringify({ last: chipNote.stats.last, chipGone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-note');
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-play', port: 'exec' };
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(insId)});`);
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(`
(async () => {
const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer());
const file = new File([bytes], 'drone.wav', { type: 'audio/wav' });
const data = new DataTransfer();
data.items.add(file);
const canvas = document.querySelector('canvas');
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
return file.size;
})()`);
await waitFor(
() => assetsOfType('sound'),
(list) => list.length === 2,
);
const both = await assetsOfType('sound');
check(
'a dropped sound is uploaded and becomes an asset named after the file',
dropped === DRONE_BYTES &&
both.length === 2 &&
both.some((a) => a.file === drone.file && a.name === 'drone'),
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its
// clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a
// click picks one, and the picked clip stands between two handles. A clip is played by a
// name of its own, its samples cut to the sample and ramped from silence at an edge inside
// the file. One drag is one undo step.
const droneId = both.find((a) => a.file === drone.file)?.id;
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const tab = () => json('__gm.soundTab.span()');
const clips = () => json('__gm.soundTab.clips()');
const picked = () => json('__gm.soundTab.pickedClip()');
await waitFor(tab, (span) => span !== null);
const tabOpened = {
current: await json('__gm.soundTab.current()'),
span: await tab(),
info: await json('__gm.soundTab.info()'),
clips: await clips(),
picked: await picked(),
reverse: await json('__gm.soundTab.reverseButton()'),
played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`),
};
check(
'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade',
tabOpened.current === droneId &&
tabOpened.span?.start === 0 &&
Math.abs(tabOpened.span?.end - 0.75) < 0.001 &&
tabOpened.info === '0.75 s' &&
tabOpened.clips.length === 0 &&
tabOpened.picked === null &&
tabOpened.reverse.enabled === false &&
tabOpened.played === drone.file,
JSON.stringify(tabOpened),
);
// 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room
// above a full-scale wave, in takes it past the top of the tab, the seconds stay where they
// are, and Fit brings back the full height.
const levelScale = await json('__gm.soundTab.scale()');
const levelRect = await json('__gm.soundTab.rect()');
const fullTop = await json('__gm.soundTab.fullScaleTop()');
const secondAt = (await json('__gm.soundTab.at(0.5)')).x;
await t.wheel(levelScale.x, levelScale.y, 924);
const zoomedOut = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
secondAt: (await json('__gm.soundTab.at(0.5)')).x,
};
await t.wheel(levelScale.x, levelScale.y, -1848);
const zoomedIn = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
};
const levelFit = await json('__gm.soundTab.fitButton()');
await click(levelFit.x, levelFit.y);
await sleep(100);
const refitted = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
};
check(
'the wheel over the level scale zooms the height alone, and Fit puts it back',
Math.abs(zoomedOut.level - 0.25) < 0.01 &&
Math.abs(zoomedOut.top - levelScale.y - (fullTop - levelScale.y) * 0.25) < 1 &&
zoomedOut.secondAt === secondAt &&
Math.abs(zoomedIn.level - 4) < 0.05 &&
zoomedIn.top < levelRect.y &&
refitted.level === 1 &&
Math.abs(refitted.top - fullTop) < 0.5,
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
);
// #377: at 1000 ms a frame after the wheel the zoom is on its way, on the level scale and on the wave alike.
const waveZoom = (key) => `({ scale: __gm.soundTab.zoom().${key}, moving: __gm.soundTab.zoom().moving })`;
const waveLevel = await sampleZoom(t, levelScale, waveZoom('level'));
const waveTime = await sampleZoom(t, await json('__gm.soundTab.at(0.5)'), waveZoom('pxPerSecond'));
check(
'a zoom of the height and of the time is shown on its way and lands where the jump does',
waveLevel.ok && waveTime.ok,
`${waveLevel.detail} ${waveTime.detail}`,
);
// 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360):
// drawing both anew on every move took the frames down.
const panFrom = await json('__gm.soundTab.at(0.375)');
await t.wheel(panFrom.x, panFrom.y, -924);
const panInfo = async () => ({
...(await json('__gm.soundTab.drawInfo()')),
at: (await json('__gm.soundTab.at(0.375)')).x,
});
const panBefore = await panInfo();
await t.mouse('mouseMoved', panFrom.x, panFrom.y);
await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle');
for (let i = 1; i <= 8; i++) {
await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 });
await sleep(20);
}
await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle');
await sleep(100);
const panAfter = await panInfo();
const panMoved = panAfter.at - panBefore.at;
await click(levelFit.x, levelFit.y);
await sleep(100);
check(
'a pan moves the wave drawn already, and the ruler labels keep their seconds',
Math.abs(panMoved + 240) < 1 &&
panAfter.waveDraws === panBefore.waveDraws &&
Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 &&
panAfter.rulerTexts - panBefore.rulerTexts <= 2,
JSON.stringify({ panBefore, panAfter, panMoved }),
);
const clipProps = async () => (await clips())[0]?.props;
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
let dragTo = await json('__gm.soundTab.stripAt(0.3)');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') };
const clipId = spanned.clips[0]?.id;
check(
'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file',
spanned.clips.length === 1 &&
spanned.clips[0].name === 'Clip 1' &&
Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 &&
Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 &&
spanned.picked === clipId &&
spanned.info ===
`${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` &&
(await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file,
JSON.stringify(spanned),
);
await press('z', { ctrl: true });
const undoneClip = { clips: await clips(), picked: await picked() };
await press('z', { ctrl: true, shift: true });
const redoneClip = await clips();
const beside = await json('__gm.soundTab.stripAt(0.5)');
await click(beside.x, beside.y);
const pickedBeside = await picked();
const faintBeside = await json('__gm.soundTab.faintClips()');
await shot('34-sound-faint-clip');
const onBand = await json('__gm.soundTab.stripAt(0.2)');
await click(onBand.x, onBand.y);
const faintPicked = await json('__gm.soundTab.faintClips()');
check(
'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound, the clip then faint over the wave',
undoneClip.clips.length === 0 &&
undoneClip.picked === null &&
redoneClip.map((c) => c.id).join() === clipId &&
pickedBeside === null &&
faintBeside.join() === 'Clip 1' &&
faintPicked.length === 0 &&
(await picked()) === clipId,
JSON.stringify({
undoneClip,
redoneClip,
pickedBeside,
faintBeside,
faintPicked,
picked: await picked(),
}),
);
const inClip = await json('__gm.soundTab.at(0.2)');
await click(inClip.x, inClip.y);
const pickedInClip = await picked();
const besideClip = await json('__gm.soundTab.at(0.6)');
await click(besideClip.x, besideClip.y);
const pickedBesideClip = await picked();
await click(onBand.x, onBand.y);
check(
'a click over the wave inside the picked clip keeps it, beside it picks the sound',
pickedInClip === clipId && pickedBesideClip === null && (await picked()) === clipId,
JSON.stringify({ pickedInClip, pickedBesideClip, picked: await picked() }),
);
dragFrom = await json('__gm.soundTab.at(0.3)');
dragTo = await json('__gm.soundTab.at(0.5)');
await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y);
await t.mouse('mouseMoved', dragFrom.x, dragFrom.y);
const onHandle = await json('__gm.soundTab.cursor()');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const lengthened = await clipProps();
await press('z', { ctrl: true });
check(
'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged',
onHandle === 'ew-resize' &&
Math.abs(lengthened?.start - 0.1) < 0.01 &&
Math.abs(lengthened?.end - 0.5) < 0.01 &&
Math.abs((await clipProps())?.end - 0.3) < 0.01,
JSON.stringify({ onHandle, lengthened, undone: await clipProps() }),
);
const clipCut = await clipProps();
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`);
const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1;
check(
'the clip is played by a name of its own, cut to the sample',
cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` &&
cutSamples?.length === keptSamples,
JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }),
);
check(
'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file',
cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01,
JSON.stringify(cutSamples),
);
// 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345).
const tabFadeOut = await json('__gm.soundTab.fadeOutField()');
await click(tabFadeOut.x, tabFadeOut.y);
await t.typeText('40');
await press('Enter');
await sleep(150);
const curveOutButton = await json('__gm.soundTab.curveOutButton()');
await click(curveOutButton.x, curveOutButton.y);
await clickText('Exponential');
await sleep(150);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`);
check(
'the header sets the fade out and its curve on their own, the fade in staying Linear',
(await clipProps())?.fadeOut === 40 &&
(await clipProps())?.fadeOutCurve === 'exponential' &&
((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' &&
(await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' &&
curved.endsWith('~0.005~0.04~linear~exponential'),
JSON.stringify({ props: await clipProps(), curved }),
);
await press('z', { ctrl: true });
await press('z', { ctrl: true });
const tabFadeIn = await json('__gm.soundTab.fadeInField()');
await click(tabFadeIn.x, tabFadeIn.y);
await t.typeText('0');
await press('Enter');
const fadeOutField = await json('__gm.soundTab.fadeOutField()');
await click(fadeOutField.x, fadeOutField.y);
await t.typeText('0');
await press('Enter');
await sleep(150);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
check(
'after two undos the curve is Linear again, and both fades go to 0 in the header',
(await clipProps())?.fadeIn === 0 &&
(await clipProps())?.fadeOut === 0 &&
((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
unfaded.endsWith('~0~0~linear~linear'),
JSON.stringify({ props: await clipProps(), unfaded }),
);
// The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the
// exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp.
const rawSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(unfaded)})`);
const outRamp = Math.round(0.04 * (rawSamples?.rate ?? 0));
const fall = [1, 2, 3]
.map((i) => ({ i, raw: rawSamples?.tail.at(-1 - i), curved: curvedSamples?.tail.at(-1 - i) }))
.filter((s) => Math.abs(s.raw) > 1e-3);
check(
'the end falls along the curve: (i / ramp)² of the drone on an exponential one',
curvedSamples?.tail.at(-1) === 0 &&
fall.length > 0 &&
fall.every((s) => Math.abs(s.curved / s.raw / (s.i / outRamp) ** 2 - 1) < 1e-3),
JSON.stringify({ fall, outRamp }),
);
await evaluate('__gm.sound.stopAll()');
const tabPlay = await json('__gm.soundTab.playButton()');
await click(tabPlay.x, tabPlay.y);
const tabHeard = await waitFor(stats, (s) => s.last?.file === unfaded);
const tabPlayhead = await waitFor(
() => json('__gm.soundTab.playhead()'),
(seconds) => seconds !== null && seconds > 0.1,
10,
);
const tabEnded = await waitFor(
() => json('__gm.soundTab.playing()'),
(on) => on === false,
);
check(
'Play plays only the picked clip, its playhead running inside it, and stops when it ends',
tabHeard.last?.file === unfaded &&
Math.abs(tabHeard.last?.duration - 0.2) < 0.01 &&
tabPlayhead !== null &&
tabPlayhead >= 0.1 &&
tabPlayhead <= 0.3 &&
tabEnded === false &&
(await json('__gm.soundTab.playhead()')) === null,
JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }),
);
// 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321).
const reverseButton = await json('__gm.soundTab.reverseButton()');
await click(reverseButton.x, reverseButton.y);
await sleep(150);
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`);
check(
'Reverse turns the clip around: its first samples are the last of the clip played forwards',
(await clipProps())?.reverse === true &&
(await json('__gm.soundTab.reverseButton()')).on === true &&
reversed === `${unfaded}~reverse` &&
reversedSamples?.length === rawSamples?.length &&
reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)),
JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }),
);
// Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged
// past both ends of it.
for (const [from, to] of [
[0.1, -0.1],
[0.3, 0.9],
]) {
const a = await json(`__gm.soundTab.at(${from})`);
const b = await json(`__gm.soundTab.at(${to})`);
await t.drag(a.x, a.y, b.x, b.y);
}
const whole = await clipProps();
await click(tabPlay.x, tabPlay.y);
await waitFor(
() => json('__gm.soundTab.playhead()'),
(seconds) => seconds !== null,
10,
);
const backwards = (
await trace(
() => json('__gm.soundTab.playhead()'),
(seconds) => seconds === null,
)
).filter((seconds) => seconds !== null);
check(
'played backwards, the playhead runs from the end of the clip towards its start',
whole?.reverse === true &&
whole?.start === 0 &&
whole?.end === 0 &&
backwards.length >= 2 &&
backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) &&
backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) &&
backwards.at(-1) < backwards[0],
JSON.stringify({ whole, backwards }),
);
await shot('34-sound-tab');
// 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1".
await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' });
// The row is named after the port, as the node body names it.
const soundRow = await json("__gm.inspectorField('sound', true)");
await click(soundRow.x, soundRow.y);
const soundOffered = await json('__gm.overlayTexts()');
await clickText('drone / Clip 1');
await sleep(150);
const playNode = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find(
(n) => n.id === 'sn-play',
);
check(
'a sound field offers each clip after its sound, and picking one names the clip',
soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 &&
(await playNode())?.params.sound === clipId,
JSON.stringify({ soundOffered, node: await playNode() }),
);
// 34h-v. Delete takes the picked clip away, and the sound stays.
await evaluate(
`__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`,
);
await waitFor(picked, (id) => id === clipId);
await press('Delete');
check(
'Delete takes the picked clip away, the sound staying on screen',
(await clips()).length === 0 &&
(await picked()) === null &&
(await json('__gm.soundTab.current()')) === droneId,
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
);
// 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
// one; the tab has a node for it and for the sound, each linked to the master. Links drawn
// there and one deleted send the sound into a bus and that bus into the other; the Sound
// tab still plays it on the master (#368). A click on a node shows its asset, on the
// master the master's volume.
for (let i = 0; i < 2; i++) {
await clickText('+');
await pickAssetType(t, 'Bus');
await sleep(150);
}
const [busA, busB] = await assetsOfType('bus');
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(300);
const routing = () => json('__gm.routing.graph()');
const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() };
const labels = await json(
`[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`,
);
await select({ kind: 'asset', assetId: droneId });
const busRow = await json("__gm.inspectorField('Bus', true)");
await select({ kind: 'asset', assetId: busA.id });
const intoRow = await json("__gm.inspectorField('Into', true)");
check(
'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows',
opened.shown &&
[droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) &&
[droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) &&
JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) &&
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) &&
busRow === null &&
intoRow === null,
JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }),
);
const portOf = (nodeId, key, isOutput) =>
json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const link = async (from, to) => {
const a = await portOf(from, 'out', true);
const b = await portOf(to, 'in', false);
await drag(a.x, a.y, b.x, b.y);
await sleep(200);
};
// The link is picked by the model: its line may run behind a node, as lines do.
const unlink = async (from, to) => {
const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to);
await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] });
await press('Delete');
await sleep(150);
};
await link(droneId, busB.id);
await link(busB.id, busA.id);
await unlink(droneId, '#master');
await unlink(busB.id, '#master');
await shot('h3-routing');
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
// The preview of a tab leaves the routing out (#368): heard on the master, the buses stay silent.
const tapped = JSON.stringify(['mix', `mix:${busB.id}`]);
await json(`__gm.routing.peaks(${tapped})`);
const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10);
await sleep(300);
const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus;
const peak = await waitFor(
() => json(`__gm.routing.peaks(${tapped})`),
(peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01,
);
await click(tabPlay.x, tabPlay.y);
const levelOf = (name) => buses.find((bus) => bus.name === name);
check(
'linked in the routing, the mix sends the sound’s bus into the other; the Sound tab plays it on the master, past both',
links(await routing())
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.id))
.join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` &&
heardOn === 'mix' &&
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null &&
Math.max(...(peak?.mix ?? [0])) > 0.01 &&
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) < 0.001,
JSON.stringify({ links: links(await routing()), heardOn, buses, peak }),
);
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(200);
let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`);
await click(at.x, at.y);
await sleep(200);
const busShown = await json("__gm.inspectorField('Volume', true)");
at = await json("__gm.routing.nodeAt('#master')");
await click(at.x, at.y);
await sleep(200);
await shot('h3-master');
const masterField = await json("__gm.inspectorField('Volume', true)");
await click(masterField.x, masterField.y);
await press('a', { ctrl: true });
await t.typeText('0.6');
await press('Enter');
await sleep(150);
const masterSet = await json('__gm.state.project.masterVolume');
await t.clickToolbar('undo');
await sleep(150);
const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') };
at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`);
await click(at.x, at.y);
await click(at.x, at.y);
await sleep(300);
check(
'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound',
busShown !== null &&
masterShown.set === 0.6 &&
masterShown.undone === 1 &&
(await json('__gm.soundTab.current()')) === droneId &&
(await json('__gm.routing.shown()')) === false,
JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }),
);
await t.clickToolbar('menu');
await clickText('Project settings…');
const masterAt = await json('__gm.projectSettingsMasterAt()');
await click(masterAt.volume.x, masterAt.volume.y);
await press('a', { ctrl: true });
await t.typeText('0.6');
await press('Enter');
await clickText('Apply');
await sleep(200);
const mastered = {
project: await json('__gm.state.project.masterVolume'),
heard: await json("__gm.sound.mixVolume('mix')"),
};
await t.clickToolbar('undo');
await sleep(200);
const undone = {
project: await json('__gm.state.project.masterVolume'),
heard: await json("__gm.sound.mixVolume('mix')"),
};
check(
'the master volume is a project setting the mix follows, and undo gives the old one back',
mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
JSON.stringify({ masterAt, mastered, undone }),
);
// The routing starts again from the assets with the next edit: everything on the master.
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.type !== 'bus');
p.routing = { nodes: [], edges: [] };`);
// 34h-4. A long sound streamed (#349): five minutes of mono, decoded whole some 55 MB. With Stream
// on, the editor makes a copy of it as Opus and a game plays from that, through its bus and
// the master; the renderer grows by a few megabytes while it plays, not by the decoded
// file. The counter-proof right after turns Stream off: decoded, it grows by about that much.
const long = await uploadSound(t.baseUrl, sineWav(300, 330), 'long.wav');
// The tab's renderer is by far the largest. Summed, a renderer Chrome starts meanwhile counted too.
const renderers = () =>
execSync('ps -eo rss,args')
.toString()
.split('\n')
.filter((line) => line.includes('--type=renderer'))
.map((line) => Number(line.trim().split(/\s+/)[0]) / 1024);
const rendererMb = () => Math.max(...renderers());
const restingMb = async () => {
await t.cdp.send('HeapProfiler.collectGarbage');
await sleep(1500);
return rendererMb();
};
// What the game runs: On start plays the long sound, or a song.
const startWith = (played) =>
mutate(`
__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph = ${JSON.stringify({
nodes: [node('sn-start', 'OnStart', 0, 0), played],
edges: [edge('sn-e1', 'sn-start', 'exec', played.id, 'exec')],
})};`);
const playsLong = node('sn-play', 'PlaySound', 260, 0, { sound: 'sn-long', volume: 1, loop: '' });
await evaluate('__gm.sound.stopAll()');
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: 'sn-long', type: 'sound', name: 'long', props: { volume: 1, loop: true, fx: '', stream: true }, file: ${JSON.stringify(long.file)} },
{ id: 'sn-bus', type: 'bus', name: 'Music', props: { volume: 1, fx: '' } },
);
p.routing = {
nodes: [
{ id: 'sn-long', type: 'RoutedAsset', x: 0, y: 0, params: { name: 'long', kind: 'sound' } },
{ id: 'sn-bus', type: 'RoutedAsset', x: 320, y: 0, params: { name: 'Music', kind: 'bus' } },
{ id: '#master', type: 'RoutedMaster', x: 640, y: -80, params: {} },
],
edges: [
{ id: 'sn-r1', from: { node: 'sn-long', port: 'out' }, to: { node: 'sn-bus', port: 'in' } },
{ id: 'sn-r2', from: { node: 'sn-bus', port: 'out' }, to: { node: '#master', port: 'in' } },
],
};`);
await startWith(playsLong);
await evaluate('__gm.streams.idle()');
const copies = await json("__gm.streams.copies('sn-long')");
const streamed = (await json('__gm.streams.table()')).find((entry) => entry.name === long.file);
check(
'with Stream on the editor makes a copy of the long sound as Opus, five minutes to the sample, and a game streams it from there',
copies?.sound?.from === long.file &&
copies.sound.frames === 300 * 48000 &&
copies.sound.bytes > 0 &&
streamed?.file === copies.sound.file &&
streamed.frames === 300 * 48000,
JSON.stringify({ long, copies, streamed }),
);
const statsBefore = await stats();
const decodedBefore = statsBefore.decoded;
const restStreamed = await restingMb();
const renderersBefore = renderers();
await play();
const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']);
await json(`__gm.routing.peaks(${tapsLong})`);
const streaming = await waitFor(stats, (s) => s.streaming.playing > 0, 40);
const heardLong = await waitFor(
() => json(`__gm.routing.peaks(${tapsLong})`),
(peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01,
);
await sleep(1000);
// After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614).
const playingStreamed = await restingMb();
const renderersPlaying = renderers();
const heldStreamed = await stats();
await t.clickToolbar('pause');
await sleep(500);
const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') };
await sleep(500);
const pausedLater = await json('__gm.sound.clock()');
await t.clickToolbar('pause');
await sleep(1000);
const resumedLong = await stats();
await stop();
const stoppedLong = await stats();
check(
'played, the streamed sound is heard on its bus and the master, none of the file decoded',
streaming?.last?.file === long.file &&
streaming.last.bus === 'mix:sn-bus' &&
streaming.decoded === decodedBefore &&
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
JSON.stringify({ streaming, heardLong }),
);
check(
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
paused.state === 'suspended' &&
pausedLater === paused.clock &&
resumedLong.streaming.playing === 1 &&
resumedLong.streaming.skipped === 0 &&
stoppedLong.streaming.playing === 0 &&
stoppedLong.playing === 0,
JSON.stringify({ paused, pausedLater, resumedLong: resumedLong.streaming, stoppedLong }),
);
// The counter-proof: Stream off, the game plays it decoded, and the renderer holds all of it.
// Measured from before it is switched off, as from then on the editor decodes it ahead, and before
// anything below has had large buffers made: Chrome does not hand all of that back at once.
await evaluate('__gm.sound.stopAll()');
const restDecoded = await restingMb();
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
await play();
const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60);
await sleep(1000);
const playingDecoded = await restingMb();
await stop();
await evaluate('__gm.streams.idle()');
check(
'with Stream off the same sound is decoded whole, and the renderer grows by more than 40 MB',
decodedNow?.streaming.playing === 0 &&
decodedNow.decoded > decodedBefore &&
playingDecoded - restDecoded > 40 &&
(await json("__gm.streams.copies('sn-long')"))?.sound === null,
JSON.stringify({ restDecoded, playingDecoded, decodedNow }),
);
// What the sound library holds of it, counted by the library: streamed at most 4 s sent ahead on top
// of what it held before (its head among the heads of every copy), decoded all five minutes: 50 MB
// at 44.1 kHz.
// Not the growth of the renderer: after the modules before this one it grew by 25 to 66 MB while
// streaming, alone by 1 to 8 (#378, CI tasks 611 to 617). That growth stays in the log.
const mb = (bytes) => bytes / 2 ** 20;
const kept = {
before: mb(statsBefore.streaming.held),
streamed: mb(heldStreamed.streaming.held - statsBefore.streaming.held),
streamedWhole: mb(heldStreamed.decodedBytes - statsBefore.decodedBytes),
decodedWhole: mb((decodedNow?.decodedBytes ?? 0) - statsBefore.decodedBytes),
};
check(
'streamed, the page holds a few seconds of its samples and none decoded whole, decoded all five minutes',
kept.streamed > 0 && kept.streamed < 4 && kept.streamedWhole === 0 && kept.decodedWhole > 40,
JSON.stringify({
kept,
grownStreamed: playingStreamed - restStreamed,
grownDecoded: playingDecoded - restDecoded,
renderersBefore,
renderersPlaying,
}),
);
// The Sound tab points a long sound at the switch, and says once it streams.
await evaluate("__gm.state.openAsset('sn-long')");
await sleep(300);
const longInfo = await json('__gm.soundTab.info()');
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = true;`);
await evaluate('__gm.streams.idle()');
await sleep(200);
const streamedInfo = await json('__gm.soundTab.info()');
check(
'the Sound tab suggests Stream for a sound of a minute or more, and says when it is streamed',
longInfo === '300.00 s · long, try Stream' && streamedInfo === '300.00 s · streamed',
JSON.stringify({ longInfo, streamedInfo }),
);
// On a track of a song it starts on its beat, a clip of its own streamed from a copy of its cut.
await mutate(`
const sound = __gm.state.project.assets.find((a) => a.id === 'sn-long');
sound.clips = [{ id: 'sn-long-clip', name: 'Middle', props: { start: 10, end: 20, fadeIn: 5, fadeOut: 5, fadeInCurve: 'linear', fadeOutCurve: 'linear', reverse: true } }];`);
await clickText('+');
await pickAssetType(t, 'Song');
await sleep(200);
const [longSong] = await assetsOfType('song');
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(longSong.id)});
song.items = [{ id: 'sn-i1', track: song.tracks[0].id, kind: 'sound', asset: 'sn-long-clip', tick: 0, length: 3840, offset: 0, volume: 0, transpose: 0, instrument: '', fx: '' }];`);
await evaluate('__gm.streams.idle()');
await startWith(
node('sn-song', 'PlaySong', 260, 0, { song: longSong.id, volume: 1, fadeIn: 0, loop: false }),
);
await showTab('scene');
await play();
const songRun = await waitFor(
async () => ({ runs: await json('__gm.sound.playbacks()'), stats: await stats() }),
(got) => got.runs[0]?.clips.length > 0 && got.stats.streaming.playing > 0,
40,
);
await stop();
const clipCopy = (await json("__gm.streams.copies('sn-long')"))?.clips[0]?.stream;
const clipName = await json("__gm.soundTab.played('sn-long-clip')");
const songClip = songRun?.runs[0]?.clips[0];
check(
'a clip of the streamed sound gets a copy of its cut, and a song streams it on its track, started on its beat',
clipCopy?.from === clipName &&
clipCopy.frames === 10 * 48000 &&
songClip?.sound === 'sn-long-clip' &&
songRun.stats.last?.file === clipName &&
Math.abs(songRun.stats.last.at - songClip.at) < 1e-6,
JSON.stringify({ clipCopy, clipName, songClip, last: songRun?.stats.last }),
);
// Offline, to the sample: from 0.25 s on, two seconds into the copy; round the seam of its loop;
// and the reversed clip from its start – each against the copy decoded whole.
const exactly = async (...args) =>
JSON.parse(await evaluate(`__gm.streams.exact(...${JSON.stringify(args)}).then(JSON.stringify)`));
const exact = {
start: await exactly(long.file, 2, false, 0.25, 2),
seam: await exactly(long.file, 299, true, 0, 2.5),
clip: await exactly(clipName, 9, true, 0.1, 2),
};
check(
'streamed, it starts on its sample, runs on past where it jumped in and round its loop as the copy decoded whole',
Object.values(exact).every(
(run) =>
run !== null &&
run.own < 1e-6 &&
run.browser < 0.01 &&
run.skipped === 0 &&
run.decoded === run.frames,
),
JSON.stringify(exact),
);
// 34h-5. With Stream off the clip keeps its copy, and a game only playing the sound through it
// decodes the copy, not the five minutes. The counter-proof plays the sound itself: then the
// whole file is decoded ahead again.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
await evaluate('__gm.streams.idle()');
const keptCopies = await json("__gm.streams.copies('sn-long')");
const freshly = async () =>
JSON.parse(await evaluate(`__gm.streams.fresh(${JSON.stringify(clipName)}).then(JSON.stringify)`));
const fresh = await freshly();
check(
'with Stream off the clip keeps a copy of its cut, and a game decodes that rather than the whole file, as the cut sounds',
keptCopies?.sound === null &&
keptCopies.clips[0]?.stream?.from === clipName &&
fresh?.ahead.includes(clipName) &&
!fresh.ahead.includes(long.file) &&
fresh.whole === false &&
fresh.decoded === fresh.ahead.length &&
fresh.played?.length === 10 * 48000 &&
fresh.played.wanted === fresh.played.length &&
fresh.played.level < 0.5 &&
fresh.played.worst < 0.1,
JSON.stringify({ keptCopies, fresh }),
);
await startWith(playsLong);
const freshWhole = await freshly();
check(
'once the game plays the sound itself, its whole file is decoded ahead again',
freshWhole?.ahead.includes(long.file) &&
!freshWhole.ahead.includes(clipName) &&
freshWhole.whole === true,
JSON.stringify({ ahead: freshWhole?.ahead, whole: freshWhole?.whole }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !['sn-long', 'sn-bus', ${JSON.stringify(longSong.id)}].includes(a.id));
p.routing = { nodes: [], edges: [] };`);
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => !['sn-box', 'sn-brain'].includes(o.id));
__gm.state.select(null);
__gm.lookAt(0, 0);`);
check(
'the step leaves nothing of its own behind',
(await assetsOfType('sound')).length === 0 && (await stats()).playing === 0,
JSON.stringify(await assetsOfType('sound')),
);
}