Files
feuerwerk/tests/core/music-events.test.ts
T
adminandClaude Opus 5.5 53ec1094f4 Let a game switch songs and turn their tracks on the beat (#338)
A game could start and stop a song, but not steer one while it plays.
The battle theme could not take over on the next bar, a song could not
jump to a marker, and a track could not fall away when the player
hides. This is stage 3 of 5 in #338.

- Quantize: now, next beat, next bar (the default) or next marker. The
  spot is the next one as the song is heard, and the change is planned
  on the audio clock to the sample, round the loop as the ruler counts.
- Set song track turns one track in dB on top of the song's level, over
  a fade, on the bus input before its FX and sends.
- Switch song plays another song, or the same one from a marker, in
  place of the playing one. It keeps the playback number, so Stop song,
  Set song track and On beat go on reaching it.
- A playing now has a key for its buses apart from its public number.
  During a switch the old and the new song each have buses of their own.
- Play song has a "live tracks" switch. With it off a baked song plays
  its bake, and Set song track on it writes a line to the log.
- A Switch song to a marker plays the notes, as a bake only plays from
  its start.

Found on the way:

- A cut on a bar line faded over seconds in Chrome. cancelAndHoldAtTime
  holds nothing where nothing is planned later, so the ramp ran from the
  bus's last change. The turn now sets its start value on the spot. It
  had been measured at 0.52 of full level 0.2 s past the bar line.

Tests:
- Unit: 23 new cases in beats, song-play, sound-library and the node
  catalog. Counter-checks: 17 bends, all compile, all red.
- e2e 65 (atx): ALL PASSED, 73 checks. 65k3 is new. The switch landed
  three bars after the start under the same number, and the track cut
  on the next bar line with nothing in between. With the old ramp 65k3
  is red (0.53 and 0.51 between).
- Gate (atx): CHECK EXIT 0, 334 files, 5144 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:17:52 +01:00

231 lines
8.0 KiB
TypeScript

/**
* A game follows its music (#338): On beat, On bar and On song marker run as the songs and patterns
* it plays pass them, as heard, and Song position says where one stands.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { HeardTime } from '../../src/core/music/playbacks';
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { heardPlayings, positionOf } from '../../src/runtime/systems/musicPulses';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
const Q = 480;
const BAR = 4 * Q;
/** A song with two markers on bar 2 and one in its middle, a pattern, and a behavior to listen. */
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');
f.stack.execute(new SetAssetPropertyCommand(pattern, 'sign', '3/4'));
f.stack.execute(
new SetSongMarkersCommand(song, [
{ id: 'verse', name: 'Verse', tick: BAR },
{ id: 'also', name: 'Also', tick: BAR },
{ id: 'half', name: 'Half', tick: BAR / 2 },
]),
);
const behavior = makeBehavior(f, 'Logic');
place(f, behavior.id, { x: 0, y: 0 });
const edit = graphEditor(f, behavior);
/** An event that logs `prefix` and two of its outputs, run together. */
const logs = (type: string, params: Record<string, string>, prefix: string, a: string, b: string) => {
const event = edit.addNode(type, params);
const join = edit.addNode('Join');
const first = edit.addNode('Join', { a: prefix });
edit.connect(event, a, first, 'b');
edit.connect(first, 'text', join, 'a');
edit.connect(event, b, 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, edit, logs };
}
/** A sink whose playings are heard where a test puts them. */
function heardAudio(song: string, pattern: string) {
const times: HeardTime[] = [
{
id: 7,
key: 7,
name: `song:${song}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
length: 2 * BAR,
},
{
id: 8,
key: 8,
name: `pattern:${pattern}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: 0, end: 3 * Q },
length: 3 * Q,
},
{
id: 9,
key: 9,
name: 'composition:c',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: null,
length: 2 * BAR,
},
];
return {
audio: { ...SILENT_AUDIO, playingTimes: () => times },
at(heard: number | null, ...ids: number[]) {
for (const time of times) if (ids.length === 0 || ids.includes(time.id)) time.heard = heard;
},
};
}
describe('On beat and On bar', () => {
it('run on every beat and bar a song plays, as heard, counting from 1', () => {
const f = listening();
f.logs('OnBeat', { song: f.song }, 'beat ', 'bar', 'beat');
f.logs('OnBar', { song: f.song }, 'bar ', 'bar', 'late');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect(world.log).toEqual([]);
at(0.01, 7);
frame();
expect(world.log).toEqual(['beat 11', 'bar 10.01']);
// A frame the audio clock did not move in: nothing new was heard.
frame();
expect(world.log).toHaveLength(2);
at(0.3, 7);
frame();
at(0.52, 7);
frame();
expect(world.log.slice(2)).toEqual(['beat 12']);
// Round the loop: the intro and one round are over 4 s in, the head is back on bar 2.
at(3.98, 7);
frame();
at(4, 7);
frame();
expect(world.log.slice(3)).toEqual(['beat 21', 'bar 20']);
});
it('tell nothing a stalled frame passed too long ago, and of several only the last', () => {
const f = listening();
f.logs('OnBeat', {}, 'beat ', 'bar', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.6, 7);
frame();
expect(world.log).toEqual(['beat 12']);
at(1.55, 7);
frame();
at(3.3, 7);
frame();
expect(world.log.slice(1)).toEqual(['beat 14']);
});
it('start over with a song a Switch song plays under the same number (#338)', () => {
const f = listening();
f.logs('OnBeat', {}, 'beat ', 'bar', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(3.01, 7);
frame();
expect(world.log).toEqual(['beat 23']);
// Switched to: the same number on buses of its own, its first beat heard a moment ago.
const switched = audio.playingTimes().find((time) => time.id === 7)!;
switched.key = 70;
at(0.01, 7);
frame();
expect(world.log.slice(1)).toEqual(['beat 11']);
});
it('hear the song or pattern picked, every one where none is, a composition never', () => {
const f = listening();
f.logs('OnBeat', {}, 'any ', 'playback', 'beat');
f.logs('OnBeat', { pattern: f.pattern }, 'pattern ', 'playback', 'beat');
f.logs('OnBeat', { song: f.song }, 'song ', 'playback', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.01);
frame();
expect(world.log).toEqual(['any 71', 'any 81', 'pattern 81', 'song 71']);
});
it('hear only the playing "playback" names where it is connected', () => {
const f = listening();
const beat = f.logs('OnBeat', {}, 'beat ', 'playback', 'beat');
const number = f.edit.addNode('Number', { value: 8 });
f.edit.connect(number, 'value', beat, 'playback');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.01);
frame();
expect(world.log).toEqual(['beat 81']);
});
});
describe('On song marker', () => {
it('runs for every marker on the tick passed, or for the one picked', () => {
const f = listening();
f.logs('OnSongMarker', { song: f.song }, 'any ', 'name', 'marker');
f.logs('OnSongMarker', { marker: 'also' }, 'picked ', 'name', 'playback');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(0.98, 7);
frame();
at(1.01, 7);
frame();
expect(world.log).toEqual(['any Halfhalf']);
at(2.01, 7);
frame();
expect(world.log.slice(1)).toEqual(['any Verseverse', 'any Alsoalso', 'picked Also7']);
});
});
describe('Song position', () => {
it('answers where the playing picked stands, the one started last where several are', () => {
const f = listening();
const { audio, at } = heardAudio(f.song, f.pattern);
at(1.25, 7);
at(0.625, 8);
const playings = heardPlayings(f.project, audio);
// The pattern is in 3/4 and runs round its one bar of 1.5 s.
expect(playings.map((p) => [p.kind, p.timeline.meter.beats])).toEqual([
['song', 4],
['pattern', 3],
]);
expect(positionOf(playings, null, f.song, '')).toMatchObject({
bar: 1,
beat: 3,
fraction: 0.5,
seconds: 1.25,
playing: true,
});
// None picked: the pattern, started after the song.
expect(positionOf(playings, null, '', '')).toMatchObject({ beat: 2, fraction: 0.25, seconds: 0.625 });
expect(positionOf(playings, 7, '', f.pattern)).toMatchObject({ seconds: 1.25 });
expect(positionOf(playings, 3, '', '')).toBeNull();
at(1.75, 8);
expect(positionOf(heardPlayings(f.project, audio), null, '', f.pattern)).toMatchObject({
bar: 1,
beat: 1,
fraction: 0.5,
});
});
});