/** * A game hears of the end of what it played (#373): a playing of a pattern or song tells its sink * once nothing of it is left, the sink keeps it, and On music finished and On sound finished run for * what the run itself played – once, with "stopped" where a stop ended it. */ import { beforeAll, describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands'; import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; import { INTERVAL } from '../../src/core/music/Scheduler'; import { GameRun } from '../../src/runtime/GameRun'; import { loadPhysics, readyPhysics } from '../../src/runtime/physics/engine'; import { type AudioSink, type EndedPlay, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { Input } from '../../src/runtime/systems/Input'; import { PlayedEnds } from '../../src/runtime/systems/playedEnds'; import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; const Q = 480; /** An audio clock moved by hand, its notes over as soon as they are struck; what ends is kept. */ function fakeHost() { const host = { time: 0, pumps: new Set<() => void>(), loading: [] as (() => void)[], timers: [] as { at: number; then: () => void }[], ended: [] as EndedPlay[], }; const playbacks = new Playbacks({ now: () => host.time, latency: () => 0, playing: () => false, stop: () => {}, wake: (pump) => { host.pumps.add(pump); return () => host.pumps.delete(pump); }, prepare: () => new Promise((resolve) => host.loading.push(resolve)), level: () => {}, after: (at, then) => host.timers.push({ at, then }), ended: (id, name, stopped) => host.ended.push({ id, name, stopped }), }); const run = (seconds: number) => { const end = host.time + seconds; while (host.time + INTERVAL <= end + 1e-9) { host.time += INTERVAL; for (const pump of [...host.pumps]) pump(); for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) { host.timers.splice(host.timers.indexOf(timer), 1); timer.then(); } } }; const loaded = async () => { for (const resolve of host.loading.splice(0)) resolve(); await Promise.resolve(); }; return { host, playbacks, run, loaded }; } /** A bar of four quarters at 120 BPM: two seconds long. */ function bar(loop: boolean): SequencePlay { let next = 100; return { sequence: { notes: [0, 1, 2, 3].map((i) => ({ tick: i * Q, length: Q / 2, note: 60 + i, velocity: 1, instrument: 'p', })), }, tempo: 120, length: 4 * Q, loop: loop ? { start: 0, end: 4 * Q } : null, needs: { layers: [], files: [] }, strike: () => next++, }; } describe('the end of a playing', () => { it('is told once it is played to its end, once, not stopped', async () => { const { host, playbacks, run, loaded } = fakeHost(); playbacks.start(7, 'pattern:a', bar(false), ''); await loaded(); // Heard from 0.05 s on, so over at 2.05 s; its last note is long gone and the rest planned by then. run(2); playbacks.current(); expect(host.ended).toEqual([]); run(0.1); playbacks.current(); playbacks.current(); expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: false }]); }); it('with a loop never by itself, only by a stop – after its fade out', async () => { const { host, playbacks, run, loaded } = fakeHost(); playbacks.start(7, 'song:a', bar(true), ''); await loaded(); run(9); playbacks.current(); expect(host.ended).toEqual([]); playbacks.stopNamed('song:a', false, 1); run(0.9); expect(host.ended).toEqual([]); run(0.2); expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]); }); it('stopped while its files load, by its owner, it is told as stopped', () => { const { host, playbacks } = fakeHost(); playbacks.start(7, 'pattern:a', bar(false), 'logic'); playbacks.stopOwner('logic'); expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: true }]); }); it('of a switch only once the number is over, by the song heard last (#338)', async () => { const { host, playbacks, run, loaded } = fakeHost(); playbacks.start(7, 'song:a', bar(true), ''); await loaded(); run(1); playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0); await loaded(); run(1); expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:b']); expect(host.ended).toEqual([]); playbacks.stopId(7); expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]); }); it('of a switch stopped while the two cross, by the one it switched to', async () => { const { host, playbacks, run, loaded } = fakeHost(); playbacks.start(7, 'song:a', bar(true), ''); await loaded(); run(1); playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 2); await loaded(); run(0.5); expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:a', 'song:b']); playbacks.stopId(7); expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]); }); it('of a switch stopped before it is heard, by the song heard then', async () => { const { host, playbacks, run, loaded } = fakeHost(); playbacks.start(7, 'song:a', bar(true), ''); await loaded(); run(1); playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0); playbacks.stopId(7); expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]); }); }); describe('PlayedEnds', () => { it('tells only what the run played, each once, a playing by the song or pattern it ended as', () => { const ends = new PlayedEnds(); ends.sound(3, 'blip'); ends.playing(4); ends.playing(5); ends.playing(0); const log: EndedPlay[] = [ { id: 2, name: 'x.ogg', stopped: false }, { id: 3, name: 'b.ogg', stopped: true }, { id: 4, name: 'song:boss', stopped: false }, { id: 5, name: 'pattern:riff', stopped: true }, ]; expect(ends.follow(log)).toEqual([ { id: 3, kind: 'sound', asset: 'blip', stopped: true }, { id: 4, kind: 'song', asset: 'boss', stopped: false }, { id: 5, kind: 'pattern', asset: 'riff', stopped: true }, ]); expect(ends.follow(log)).toEqual([]); }); }); /** * A sink that plays its patterns and songs on real playings, on the hand-moved clock, and makes a * voice of each sound that lasts until `endVoice` or a stop. */ function playingSink() { const fake = fakeHost(); let next = 1; const voices = new Map(); const endVoice = (id: number, stopped: boolean) => { const file = voices.get(id); if (file === undefined) return; voices.delete(id); fake.host.ended.push({ id, name: file, stopped }); }; const audio: AudioSink = { ...SILENT_AUDIO, play: (file) => { voices.set(next, file); return next++; }, playSequence: (name, play, owner) => { const id = next++; fake.playbacks.start(id, name, play, owner); return id; }, stopSequence: (name, letRing, fade) => fake.playbacks.stopNamed(name, letRing, fade), stopPlayback: (id, fade) => fake.playbacks.stopId(id, false, fade), stop: (file) => { if (file === '') fake.playbacks.stopNamed(''); for (const [id, of] of voices) if (file === '' || of === file) endVoice(id, true); }, stopVoice: (id) => endVoice(id, true), stopOwner: (owner) => fake.playbacks.stopOwner(owner), playing: (id) => voices.has(id) || fake.playbacks.has(id), endings: () => (fake.playbacks.current(), fake.host.ended), }; return { ...fake, audio, endVoice }; } /** A song and a pattern, each played without a loop on start, and a behavior that logs what ends. */ function listening() { const f = makeSpriteScene('Box'); const create = (type: 'song' | 'pattern', name: string) => { const command = new CreateAssetCommand(type, name); f.stack.execute(command); return command.asset.id; }; const song = create('song', 'Theme'); const pattern = create('pattern', 'Riff'); const blip = new CreateSoundCommand('Blip', `${'a1'.repeat(32)}.wav`); f.stack.execute(blip); const behavior = makeBehavior(f, 'Logic'); place(f, behavior.id, { x: 0, y: 0 }); const edit = graphEditor(f, behavior); /** An event that logs `prefix`, its number and "stopped", run together. */ const logs = (type: string, params: Record, prefix: string, number: string) => { const event = edit.addNode(type, params); const join = edit.addNode('Join'); const first = edit.addNode('Join', { a: prefix }); edit.connect(event, number, first, 'b'); edit.connect(first, 'text', join, 'a'); edit.connect(event, 'stopped', join, 'b'); const log = edit.addNode('Log'); edit.connect(event, 'exec', log, 'exec'); edit.connect(join, 'text', log, 'message'); return event; }; return { ...f, song, pattern, sound: blip.asset.id, edit, logs }; } describe('On music finished', () => { it('runs once when a song without a loop is over, not stopped; one with a loop never by itself', async () => { const f = listening(); const start = f.edit.addNode('OnStart'); const once = f.edit.addNode('PlaySong', { song: f.song, loop: false }); const round = f.edit.addNode('PlaySong', { song: f.song, loop: true }); f.edit.connect(start, 'exec', once, 'exec'); f.edit.connect(once, 'exec', round, 'exec'); f.logs('OnMusicFinished', {}, 'end ', 'playback'); const { audio, playbacks, run, loaded } = playingSink(); const { world, frame } = startWorld(f, { behaviors: true, audio }); frame(); const [first, second] = playbacks.current().map((p) => p.id); await loaded(); const length = playbacks.current()[0]!.time.length / (2 * Q); run(length - 0.5); frame(); expect(world.log).toEqual([]); run(1); frame(); frame(); expect(world.log).toEqual([`end ${first}false`]); run(3 * length); frame(); expect(world.log).toHaveLength(1); expect(playbacks.has(second!)).toBe(true); }); it('runs with "stopped" after Stop song, and hears only what its Song, Pattern or "playback" picks', async () => { const f = listening(); const start = f.edit.addNode('OnStart'); const song = f.edit.addNode('PlaySong', { song: f.song, loop: true }); const again = f.edit.addNode('PlaySong', { song: f.song, loop: true }); f.edit.connect(start, 'exec', song, 'exec'); f.edit.connect(song, 'exec', again, 'exec'); f.logs('OnMusicFinished', { pattern: f.pattern }, 'pattern ', 'playback'); f.logs('OnMusicFinished', { song: f.song }, 'song ', 'playback'); // The sink numbers the two playings 1 and 2. const picked = f.logs('OnMusicFinished', {}, 'picked ', 'playback'); f.edit.connect(f.edit.addNode('Number', { value: 1 }), 'value', picked, 'playback'); const other = f.logs('OnMusicFinished', {}, 'other ', 'playback'); f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', other, 'playback'); const key = f.edit.addNode('OnKeyDown', { key: 's' }); const stop = f.edit.addNode('StopSong', { song: f.song }); f.edit.connect(key, 'exec', stop, 'exec'); const { audio, playbacks, loaded } = playingSink(); const { world, frame, input } = startWorld(f, { behaviors: true, audio }); frame(); expect(playbacks.current().map((p) => p.id)).toEqual([1, 2]); await loaded(); input.press('s'); frame(); frame(); expect(world.log).toEqual(['song 1true', 'song 2true', 'picked 1true', 'other 2true']); }); }); describe('On sound finished', () => { it('runs once for a sound this game played, by its Sound or its "voice", with "stopped" where stopped', () => { const f = listening(); const start = f.edit.addNode('OnStart'); const first = f.edit.addNode('PlaySound', { sound: f.sound }); const second = f.edit.addNode('PlaySound', { sound: f.sound }); f.edit.connect(start, 'exec', first, 'exec'); f.edit.connect(first, 'exec', second, 'exec'); f.logs('OnSoundFinished', {}, 'any ', 'voice'); f.logs('OnSoundFinished', { sound: f.sound }, 'blip ', 'voice'); f.logs('OnSoundFinished', { sound: 'other' }, 'other ', 'voice'); const picked = f.logs('OnSoundFinished', {}, 'second ', 'voice'); f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', picked, 'voice'); f.logs('OnMusicFinished', {}, 'music ', 'playback'); const key = f.edit.addNode('OnKeyDown', { key: 's' }); const stop = f.edit.addNode('StopSound', { sound: f.sound }); f.edit.connect(key, 'exec', stop, 'exec'); const { audio, endVoice } = playingSink(); const { world, frame, input } = startWorld(f, { behaviors: true, audio }); frame(); expect([audio.playing!(1), audio.playing!(2)]).toEqual([true, true]); endVoice(1, false); frame(); frame(); expect(world.log).toEqual(['any 1false', 'blip 1false']); input.press('s'); frame(); frame(); expect(world.log.slice(2)).toEqual(['any 2true', 'blip 2true', 'second 2true']); }); it('hears nothing a run did not play itself', () => { const f = listening(); f.logs('OnSoundFinished', {}, 'any ', 'voice'); f.logs('OnMusicFinished', {}, 'music ', 'playback'); const { audio, playbacks } = playingSink(); audio.play('a.wav', 1, false, 'sound-preview'); playbacks.start(9, `song:${f.song}`, bar(false), 'pattern-preview'); playbacks.stopId(9); audio.stopVoice(1); const { world, frame } = startWorld(f, { behaviors: true, audio }); frame(); frame(); expect(world.log).toEqual([]); }); }); describe('a run', () => { beforeAll(async () => { await loadPhysics(); }); it('hears a sound it played in one scene end in the next', () => { const f = listening(); const start = f.edit.addNode('OnStart'); const play = f.edit.addNode('PlaySound', { sound: f.sound, detached: true }); const load = f.edit.addNode('LoadScene'); f.edit.connect(start, 'exec', play, 'exec'); f.edit.connect(play, 'exec', load, 'exec'); const next = new CreateAssetCommand('scene', 'Level 2'); f.stack.execute(next); load.params.scene = next.asset.id; const hearing = makeBehavior(f, 'Ears'); f.stack.execute(new PlaceAssetCommand(next.asset.id, hearing.id, { x: 0, y: 0 }, '')); const ears = graphEditor(f, hearing); const log = ears.addNode('Log', { message: 'heard' }); ears.connect(ears.addNode('OnSoundFinished', { sound: f.sound }), 'exec', log, 'exec'); const { audio, endVoice } = playingSink(); const run = GameRun.start(f.project, f.sceneId, new Input(), { audio, physics: readyPhysics()!, clock: { scale: 1 }, saves: { read: () => ({}), write: () => {} }, }); expect(run.tick(1 / 60, 1 / 60)).toBe('scene'); run.tick(1 / 60, 1 / 60); endVoice(1, false); run.tick(1 / 60, 1 / 60); expect(run.world.log).toEqual(['heard']); }); });