/** * 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; owner: string }[]; stopped: string[]; stoppedOwners: string[]; } { const sink = { played: [] as { file: string; volume: number; loop: boolean; owner: string }[], stopped: [] as string[], stoppedOwners: [] as string[], play: (file: string, volume: number, loop: boolean, owner: string) => sink.played.push({ file, volume, loop, owner }), playNote: () => 0, release: () => {}, tune: () => {}, length: () => 0, tail: () => 0, stop: (file: string) => sink.stopped.push(file), stopVoice: () => {}, stopOwner: (owner: string) => sink.stoppedOwners.push(owner), }; 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 = {}) { 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).toMatchObject([{ 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']); }); }); /** * A sound is something a thing in the world is *doing*, so it ends when that thing does – the * `Stop sound` you would otherwise have to remember in every `On destroy`. */ describe('a voice belongs to the behavior that started it', () => { it('names that behavior as its owner, and nobody when the node is detached', () => { const owned = playingScene(); const ownedAudio = fakeAudio(); const run = startWorld(owned, { behaviors: true, audio: ownedAudio }); run.frame(); const logic = run.world.entities.find((e) => e.name === 'Logic')!; expect(ownedAudio.played[0]?.owner).toBe(logic.id); const free = playingScene({ detached: true }); const freeAudio = fakeAudio(); startWorld(free, { behaviors: true, audio: freeAudio }).frame(); expect(freeAudio.played[0]?.owner).toBe(''); }); it('is silenced when the behavior is destroyed, after On destroy has had its say', () => { const f = playingScene(); const edit = graphEditor(f, f.behavior); const key = edit.addNode('OnKeyDown', { key: 'x' }); const owner = edit.addNode('Owner'); const destroy = edit.addNode('Destroy'); edit.wire([ [key, 'exec', destroy, 'exec'], [owner, 'object', destroy, 'object'], ]); const audio = fakeAudio(); const { world, input, frame } = startWorld(f, { behaviors: true, audio }); frame(); const logic = world.entities.find((e) => e.name === 'Logic')!; expect(audio.stoppedOwners).toEqual([]); input.press('x'); frame(); expect(audio.stoppedOwners).toEqual([logic.id]); }); }); 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([]); }); });