game-maker / suite (push) Successful in 12m1s
Sounds follow the road fonts already paved: the file goes to the server content-addressed under its sha256, the project keeps a named asset that points at it, and nothing is ever re-encoded. The format is read from the file's own signature, not from what the browser claims - the same .m4a arrives as audio/mp4, audio/x-m4a or as nothing at all. Playing is two nodes. "Play sound" takes the asset and may raise or lower its volume; every call is its own voice, so two jumps in a row are two jumps heard. Its loop input is a choice of three - from the asset, on, off - because a checkbox has to spend one of its two positions on "not set" and would lose a case: an asset that loops could then never be played once. Below the graph core stays browser-free: NodeContext hands out an asset id, BehaviorSystem resolves it against the project and folds the asset's own volume and loop into the call, and only viewport/SoundLibrary knows about Web Audio. A run without a sink plays nothing instead of throwing. The CI browser is muted, so the ear is no witness: the debug hook counts started and stopped voices, and step 34 reads that counter.
232 lines
9.1 KiB
TypeScript
232 lines
9.1 KiB
TypeScript
/**
|
||
* A sound is an asset holding an uploaded file. Nothing wears it and nothing places it – a graph
|
||
* plays it, and what the asset says about volume and looping is what a bare `Play sound` does.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
buildAsset,
|
||
CreateAssetCommand,
|
||
CreateSoundCommand,
|
||
SetAssetPropertyCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import type { BehaviorAsset, SoundAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { isSoundFile, soundFileOf } from '../../src/core/model/sound';
|
||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||
import { LOOP_KEY, loopsOf, VOLUME_KEY, volumeOf } from '../../src/core/properties/sounds';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
const FILE = `${'a1'.repeat(32)}.wav`;
|
||
|
||
/** Records what it was asked to play instead of making a noise – the AudioSink a test wants. */
|
||
function fakeAudio(): AudioSink & {
|
||
played: { file: string; volume: number; loop: boolean }[];
|
||
stopped: string[];
|
||
} {
|
||
const sink = {
|
||
played: [] as { file: string; volume: number; loop: boolean }[],
|
||
stopped: [] as string[],
|
||
play: (file: string, volume: number, loop: boolean) => sink.played.push({ file, volume, loop }),
|
||
stop: (file: string) => sink.stopped.push(file),
|
||
};
|
||
return sink;
|
||
}
|
||
|
||
function makeSound(name = 'Jump', file = FILE) {
|
||
const fixture = makeProject();
|
||
const command = new CreateSoundCommand(name, file);
|
||
fixture.stack.execute(command);
|
||
return { ...fixture, sound: command.asset };
|
||
}
|
||
|
||
/** A scene whose one behavior plays `sound` on start, with whatever the node itself carries. */
|
||
function playingScene(params: Record<string, string | number | boolean> = {}) {
|
||
const f: SceneFixture & { behavior?: BehaviorAsset } = makeSpriteScene('Box');
|
||
const create = new CreateSoundCommand('Jump', FILE);
|
||
f.stack.execute(create);
|
||
const behavior = makeBehavior(f, 'Logic');
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const play = edit.addNode('PlaySound', { sound: create.asset.id, volume: 1, ...params });
|
||
edit.connect(start, 'exec', play, 'exec');
|
||
return { ...f, sound: create.asset, behavior, play };
|
||
}
|
||
|
||
describe('sound files', () => {
|
||
it('accepts only a content-addressed name in a supported format', () => {
|
||
expect(isSoundFile(FILE)).toBe(true);
|
||
expect(isSoundFile(`${'b3'.repeat(32)}.mp3`)).toBe(true);
|
||
expect(isSoundFile(`${'b3'.repeat(32)}.m4a`)).toBe(true);
|
||
expect(isSoundFile(`${'a1'.repeat(32)}.png`)).toBe(false);
|
||
expect(isSoundFile(`${'a1'.repeat(32)}.WAV`)).toBe(false);
|
||
expect(isSoundFile('jump.wav')).toBe(false);
|
||
expect(isSoundFile('')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('the sound asset', () => {
|
||
it('is not placeable, starts without a file, and is full volume and one-shot by default', () => {
|
||
expect(isPlaceableType('sound')).toBe(false);
|
||
const asset = buildAsset('sound', 'Sound');
|
||
expect(asset).toMatchObject({ type: 'sound', file: '' });
|
||
expect(volumeOf(asset.props)).toBe(1);
|
||
expect(loopsOf(asset.props)).toBe(false);
|
||
expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY]);
|
||
});
|
||
|
||
it('refuses a file name the server could not have produced', () => {
|
||
const fixture = makeProject();
|
||
expect(() => fixture.stack.execute(new CreateSoundCommand('Bad', 'jump.wav'))).toThrow(
|
||
'is not a stored sound file',
|
||
);
|
||
expect(fixture.project.assets).toHaveLength(0);
|
||
});
|
||
|
||
it('survives a round trip and drops a file name it cannot trust', () => {
|
||
const s = makeSound();
|
||
expect(roundTrip(s.project)).toEqual(s.project);
|
||
|
||
const repaired = deserializeProject({
|
||
name: 'P',
|
||
assets: [
|
||
{ id: 's1', type: 'sound', name: 'Local', file: '../../etc/passwd' },
|
||
{ id: 's2', type: 'sound', name: 'Gone' },
|
||
],
|
||
});
|
||
expect(repaired.assets.map((a) => (a as SoundAsset).file)).toEqual(['', '']);
|
||
});
|
||
|
||
it('resolves to a file, and to none when the id is unset, dangling or of another type', () => {
|
||
const s = makeSound();
|
||
expect(soundFileOf(s.project, s.sound.id)).toBe(FILE);
|
||
expect(soundFileOf(s.project, '')).toBe('');
|
||
expect(soundFileOf(s.project, 'no-such-asset')).toBe('');
|
||
|
||
const text = new CreateAssetCommand('text', 'Title');
|
||
s.stack.execute(text);
|
||
expect(soundFileOf(s.project, text.asset.id)).toBe('');
|
||
});
|
||
|
||
it('is what a sound picker offers, and nothing else is', () => {
|
||
const s = makeSound();
|
||
s.stack.execute(new CreateAssetCommand('text', 'Title'));
|
||
expect(assetRefChoices(s.project, ['sound']).map((a) => a.id)).toEqual([s.sound.id]);
|
||
});
|
||
});
|
||
|
||
describe('Play sound', () => {
|
||
it('plays the asset as the project describes it', () => {
|
||
const f = playingScene();
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
expect(audio.played).toEqual([{ file: FILE, volume: 1, loop: false }]);
|
||
});
|
||
|
||
it('scales the asset volume rather than replacing it, and clamps the product', () => {
|
||
const f = playingScene({ volume: 0.5 });
|
||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, VOLUME_KEY, 0.4));
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
expect(audio.played[0]?.volume).toBeCloseTo(0.2);
|
||
|
||
const loud = playingScene({ volume: 10 });
|
||
const loudAudio = fakeAudio();
|
||
startWorld(loud, { behaviors: true, audio: loudAudio }).frame();
|
||
expect(loudAudio.played[0]?.volume).toBe(1);
|
||
});
|
||
|
||
it('loops as the asset says while the node says nothing', () => {
|
||
const asset = playingScene();
|
||
asset.stack.execute(new SetAssetPropertyCommand(asset.sound.id, LOOP_KEY, true));
|
||
const assetAudio = fakeAudio();
|
||
startWorld(asset, { behaviors: true, audio: assetAudio }).frame();
|
||
expect(assetAudio.played[0]?.loop).toBe(true);
|
||
|
||
const node = playingScene({ loop: 'true' });
|
||
const nodeAudio = fakeAudio();
|
||
startWorld(node, { behaviors: true, audio: nodeAudio }).frame();
|
||
expect(nodeAudio.played[0]?.loop).toBe(true);
|
||
});
|
||
|
||
/** The reason the node asks for three answers rather than a checkbox: music played as a one-shot. */
|
||
it('plays a looping asset once when the node says off', () => {
|
||
const f = playingScene({ loop: 'false' });
|
||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, LOOP_KEY, true));
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
expect(audio.played[0]?.loop).toBe(false);
|
||
});
|
||
|
||
it('takes a boolean arriving over an edge literally', () => {
|
||
const on = playingScene();
|
||
const onEdit = graphEditor(on, on.behavior);
|
||
onEdit.connect(onEdit.addNode('Boolean', { value: true }), 'value', on.play, 'loop');
|
||
const onAudio = fakeAudio();
|
||
startWorld(on, { behaviors: true, audio: onAudio }).frame();
|
||
expect(onAudio.played[0]?.loop).toBe(true);
|
||
|
||
const off = playingScene();
|
||
off.stack.execute(new SetAssetPropertyCommand(off.sound.id, LOOP_KEY, true));
|
||
const offEdit = graphEditor(off, off.behavior);
|
||
offEdit.connect(offEdit.addNode('Boolean', { value: false }), 'value', off.play, 'loop');
|
||
const offAudio = fakeAudio();
|
||
startWorld(off, { behaviors: true, audio: offAudio }).frame();
|
||
expect(offAudio.played[0]?.loop).toBe(false);
|
||
});
|
||
|
||
it('plays nothing for an id that resolves to no sound, and does not stop the flow', () => {
|
||
const f = playingScene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
const log = edit.addNode('Log', { message: 'after' });
|
||
edit.connect(f.play, 'exec', log, 'exec');
|
||
f.play.params.sound = 'no-such-asset';
|
||
|
||
const audio = fakeAudio();
|
||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||
frame();
|
||
expect(audio.played).toEqual([]);
|
||
expect(world.log).toEqual(['after']);
|
||
});
|
||
});
|
||
|
||
describe('Stop sound', () => {
|
||
it('silences one sound by its file, and everything when it names none', () => {
|
||
const f = playingScene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
const stop = edit.addNode('StopSound', { sound: f.sound.id });
|
||
const all = edit.addNode('StopSound', { sound: '' });
|
||
edit.connect(f.play, 'exec', stop, 'exec');
|
||
edit.connect(stop, 'exec', all, 'exec');
|
||
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
expect(audio.stopped).toEqual([FILE, '']);
|
||
});
|
||
|
||
it('silences nothing at all for an id that resolves to no sound', () => {
|
||
const f = playingScene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
const stop = edit.addNode('StopSound', { sound: 'no-such-asset' });
|
||
edit.connect(f.play, 'exec', stop, 'exec');
|
||
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
// Not `['']`: a dangling reference must not turn into "stop everything".
|
||
expect(audio.stopped).toEqual([]);
|
||
});
|
||
});
|