Files
feuerwerk/tests/core/played-ends.test.ts
T
adminandClaude Opus 5.5 e53121a0bb
feuerwerk / gate (push) Successful in 5m56s
feuerwerk / suite (push) Failing after 20m55s
Let a behavior hear a song, pattern or sound come to its end (#373)
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>
2026-10-30 14:48:16 +01:00

386 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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']);
});
});