Files
feuerwerk/tools/e2e/34-sound.mjs
T
adminandClaude Opus 5.5 183f356c99
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 14m15s
Let a game hand values to the parameters of a sound FX #311
A sound FX played the same every time: its numbers were settings of
the nodes. Now the Input declares parameters with a default, every
number of Gain, Pitch and EQ is an input they can feed, and a Play
sound hands in values for them.

- Input declares number parameters in the inspector, each with a
  Default (DeclaredPort.default, SetPortDefaultCommand). They are
  outputs of the Input. Input and Output count as terminals now, one
  of each per graph.
- Gain, Pitch and EQ take their numbers at inputs under the keys the
  settings had, so saved graphs sound as before without a migration.
  Each value is held to its range however it arrives.
- The compiler gives an FxPlan with the parameters still open.
  fxChainOf fills them in when a voice starts. The player still only
  sees an FxChain.
- New behavior node Sound FX parameters: names an FX and picks its
  parameters like a Variables node picks variables. Each picked one is
  a number input that starts at the FX's default. Its output has the
  new port type fxparams, which only fits Play sound's fx params.
- Play sound warns when the parameters name another FX than the one
  it plays. Names the FX does not know are left out.
- Renaming a parameter at the Input renames it in every Sound FX
  parameters node naming that FX, edges and values included.
- The Sound FX tab has a number field per parameter in its header.
  Play uses those values, they are never written into the asset.
- A value counts from the start of a voice. Changing a playing voice
  is #315, a Mix node is #316.

Tests:
- soundfx.test.ts 22 (+11): parameters in the plan, held values, a
  0-by-default stage, EQ band, save with defaults, wrong port types,
  default command with undo, Sound FX parameters into Play sound,
  defaults of picked ones, node problems, mismatch, rename with undo
- node-catalog 368 (+3): Play sound fx params, Sound FX parameters
- e2e 34: ALL PASSED - 28 checks (+2: header field plays depth 5,
  a Sound FX parameters node gives the game's voice depth 7)
- Gate on melektaus-atx: typecheck, lint, prettier clean, 240 test
  files, 3636 tests green
- Counter-check: with fxChainOf ignoring the values and the rename
  not followed, 5 of the new tests fail.
- Browser probe: header fields, inspector Default and the picked
  parameter looked at in two screenshots.

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

432 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
/**
* Sound assets (#45): uploaded like a typeface – picked from what the server holds through
* "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing
* wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the
* node scales the one and can overrule the other in either direction.
*
* Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness
* is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the
* witness for a repaint that did not happen. No pixel check could see any of this.
*
* The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and
* a 0.75 s drone, told apart by their exact size rather than by their name.
*/
const LANE = LANES.sound;
const BLIP_BYTES = 4044;
const DRONE_BYTES = 12044;
export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
const stats = () => json('__gm.sound.stats()');
/** A voice starts only once its bytes have been decoded, so what it changes is awaited. */
const waitFor = async (read, done, tries = 25) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
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]{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 }),
);
// 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');
// 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', 'EQ'].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);
check(
'the tab’s Play sends the sound through the graph: an octave down and one gain stage',
heardFx.last?.file === blip.file && heardFx.last.pitch === -12 && heardFx.last.stages === 1,
JSON.stringify(heardFx),
);
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?.pitch === 5 && heardParam.last.stages === 1,
JSON.stringify(heardParam),
);
await evaluate('__gm.sound.stopAll()');
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?.pitch === -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?.pitch === 7 && gameParam.last.stages === 1,
JSON.stringify(gameParam),
);
await stop();
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)});`);
// 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') }),
);
// 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')),
);
}