A game should be able to start the next piece of music when one is over, or wait for a sound before it goes on. Until now a behavior could start and stop music and sounds, but never learn that they had ended. - Two new events: On music finished with the fields Song and Pattern and the input playback, and On sound finished with the field Sound and the input voice. Both give out what ended and stopped. - Two new data nodes, Music playing and Sound playing, ask whether something is still heard. - Over means nothing is heard any more: no tail of the effects, and a fade-out has run down. - stopped is on for Stop song, pattern or sound, when the behavior that owns it ends and on a scene change. For a sound also when a newer voice needs its place or the file does not load. - Only what the Play nodes of this run started is heard, across scene changes too. Previews in the tabs, compositions and notes of instruments do not count. - Switch song ends nothing. The number goes on, and the end is told under the song heard last. - Playbacks and the SoundLibrary tell each end once to a new PlayedEnds per run. The AudioSwitch passes them on from both sinks. Found and fixed: a pattern whose last bar ends in silence counted as done up to one planning window before its end. In e2e 65 the next pattern started after 1.962 s instead of 2 s. Scheduler.done now also wants the playhead to have reached the end. Cleaning up done playbacks therefore comes a few tenths of a second later. One test that expected the early end is adjusted. Tests: - Unit: 23 new (played-ends 12, sound-library 5, node catalog 5, audio-switch 1), all green. - Counter-probes: 19 bends, all red. One was green at first for want of a case, which is added now. - e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came once with stopped off, and the second pattern started 2.051 s after the stinger. Stop song gave exactly one end with stopped on, and the switch from 65k3 gave none. - Gate on atx: CHECK EXIT 0, 337 files, 5222 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/**
|
||
* 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<void>((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<number, string>();
|
||
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<string, string>, 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']);
|
||
});
|
||
});
|