A sound could only be played as it was uploaded. A Sound FX asset is
a node graph like a shader: Input, effects, Output. Play sound has a
new input fx that sends the voice through it.
- New asset type soundfx ("Sound FX"), not placeable, with a graph
and its own tab. The heading in the asset panel is "Sound effects",
so it does not take the click meant for the + menu entry.
- Nodes in core/soundfx/nodes.ts, category sound, port type audio
that only fits audio: Input, Output, Gain (dB), Pitch (semitones),
EQ (80 Hz and 12 kHz shelves, peaks at 250 Hz, 1 kHz and 4 kHz).
- compileSoundFx walks back from the Output with the shared graph
emitter. An input takes one edge, so the result is one line: a
pitch and a list of gain and filter stages. A node without input
or a graph without Output reports a problem on the node.
- Pitch is the playback rate of the source. It applies to the whole
sound and also changes its length, the node description says so.
- The BehaviorSystem resolves fx and hands the line to the AudioSink.
An fx that is missing or does not compile plays the sound dry.
- SoundLibrary wires source, stages, volume, destination in that
order. stats().last now also names pitch and stage count.
- The tab header picks one of the project's sounds and plays it
through the graph as it stands. Stop there ends only its own voice.
Tests:
- soundfx.test.ts (new, 11), sound-library (9, +2 for the wiring),
node-catalog (+1 for fx), graph and asset-groups adjusted
- e2e 34: ALL PASSED - 26 checks (+5: + menu opens the tab, palette,
2 effects, tab Play and game Play through the FX at -12 st)
- Gate on melektaus-atx: typecheck, lint, prettier clean, 239 of 240
test files green. The red one was sound-library (its fake
AudioContext had no playbackRate), fixed afterwards, gate not
re-run in full.
- Counter-check: without the audio rule in portsCompatible the port
test fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
17 KiB
JavaScript
398 lines
17 KiB
JavaScript
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.
|
||
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),
|
||
node('fx-pitch', 'SoundFxPitch', 220, 0, { semitones: -12 }),
|
||
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'),
|
||
],
|
||
})};`);
|
||
const fxBuilt = await json('{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview() }');
|
||
check(
|
||
'Input → Pitch → Gain → Output compiles to two effects, and Play is offered',
|
||
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true,
|
||
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 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;
|
||
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')),
|
||
);
|
||
}
|