A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s
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.
This commit is contained in:
1 parent
7babf88636
commit
5671a38044
47 files changed
+2066
-42
No files matched your search
@@ -415,6 +415,10 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } },
|
||||
{ name: 'unset is no machine', outputs: { value: '' } },
|
||||
],
|
||||
Sound: [
|
||||
{ name: 'outputs the id it was set to', params: { value: 'snd-1' }, outputs: { value: 'snd-1' } },
|
||||
{ name: 'unset is no sound', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
@@ -794,6 +798,49 @@ const CASES: Record<string, Case[]> = {
|
||||
after: () => expect(emitTarget.emits).toBe(1),
|
||||
},
|
||||
],
|
||||
PlaySound: [
|
||||
{
|
||||
name: 'no sound is a no-op that still continues the flow',
|
||||
inputs: { sound: '', volume: 1, loop: '' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.played).toEqual([]),
|
||||
},
|
||||
{
|
||||
name: 'hands the id on with the volume and loop it was given',
|
||||
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
|
||||
next: 'exec',
|
||||
// What the two mean – a factor on the asset's volume, an answer that overrules its loop
|
||||
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
|
||||
// `sounds.test.ts`).
|
||||
after: (ctx) => expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true }]),
|
||||
},
|
||||
{
|
||||
name: 'saying nothing about looping leaves the answer to the asset',
|
||||
inputs: { sound: 'snd-1', volume: 1, loop: '' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.played[0]?.loop).toBeUndefined(),
|
||||
},
|
||||
{
|
||||
name: 'off is an answer of its own, not the absence of one',
|
||||
inputs: { sound: 'snd-1', volume: 1, loop: 'false' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.played[0]?.loop).toBe(false),
|
||||
},
|
||||
],
|
||||
StopSound: [
|
||||
{
|
||||
name: 'silences the sound it names',
|
||||
inputs: { sound: 'snd-1' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['snd-1']),
|
||||
},
|
||||
{
|
||||
name: 'naming none asks for everything',
|
||||
inputs: { sound: '' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['']),
|
||||
},
|
||||
],
|
||||
Log: [
|
||||
{
|
||||
name: 'logs its message and continues',
|
||||
|
||||
@@ -77,8 +77,9 @@ describe('serialize', () => {
|
||||
],
|
||||
},
|
||||
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
|
||||
// An asset type this build does not know (a newer document, a renamed type).
|
||||
{ id: 'x', type: 'sound' },
|
||||
// An asset type this build does not know (a newer document, a renamed type). It used to
|
||||
// be `sound`, which this build does know since #45 – hence a type nothing has claimed.
|
||||
{ id: 'x', type: 'video' },
|
||||
{ type: 'camera' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user