Let a behavior follow the beat, bar and markers of the music (#338)
Stage 2 of #338. A game that syncs to its music has to know where the music is heard. This stage reads that on the audio clock and hands it to behaviors as events and as a position. - New event nodes On beat (playback, bar, beat, late), On bar (playback, bar, late) and On song marker (playback, marker, name, late). - New data node Song position with bar, beat, fraction, seconds and playing. - The nodes have the fields Song and Pattern. With neither picked, every playback counts. A linked playback input picks that one. Compositions do not count. - The Marker field offers the markers of all songs as Song / Marker, as a sound field offers its clips. - Everything follows what is heard: the audio clock minus the output latency. That holds for baked songs and for Play pattern too. - Only the last beat since the frame before fires, and only when it is at most half a beat late, 0.15 s at most. Bars and markers alike. - A copied song gets markers with ids of its own, so a Marker field never names two songs. - The audio clock is only asked while something plays. Asked in every frame, it made the AudioContext without a user gesture (#436). Tests: - beats 14 and music-events 6 new, 4 in song-play, 2 in song-markers, 2 catalog cases, the touched groups 756/756 - 16 counter-probes, all compile, all red - e2e 65 on atx: ALL PASSED, 71 checks, 65k2 new: 11 beats 1.1 to 2.4 and 2.1 again, none missing or twice, across the pause too. late 0.015 to 0.027 s. Counter-probe without the last position: red - gate on atx: CHECK EXIT 0, 334 files, 5121 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5cc1cc9ef6
commit
a6a41b6b69
25 files changed
+1171
-12
No files matched your search
+63
-1
@@ -936,8 +936,14 @@ export async function song(t) {
|
||||
{ ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 },
|
||||
{ ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 },
|
||||
];
|
||||
song.loop = { start: ${BAR}, end: ${2 * BAR} };`);
|
||||
song.loop = { start: ${BAR}, end: ${2 * BAR} };
|
||||
song.markers = [{ id: 'sg65-mark', name: 'Round', tick: ${BAR} }];`);
|
||||
await showTab('scene');
|
||||
// 65k2 hears it through these (#338): each beat logs bar, beat, how late it is told and where
|
||||
// Song position stands then, each marker its name.
|
||||
const joined = (key, a, b, x) => [
|
||||
node(key, 'Join', x, 400, { ...(a !== null && { a }), ...(b !== null && { b }) }),
|
||||
];
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'sg65-logic',
|
||||
name: 'Music 65',
|
||||
@@ -950,10 +956,38 @@ export async function song(t) {
|
||||
node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }),
|
||||
node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }),
|
||||
node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }),
|
||||
node('sg65-beat', 'OnBeat', 0, 400, { song: id }),
|
||||
node('sg65-pos', 'SongPosition', 0, 600, { song: id }),
|
||||
...joined('sg65-j1', 'beat ', null, 200),
|
||||
...joined('sg65-j2', null, '.', 400),
|
||||
...joined('sg65-j3', null, null, 600),
|
||||
...joined('sg65-j4', null, ' ', 800),
|
||||
...joined('sg65-j5', null, null, 1000),
|
||||
...joined('sg65-j6', null, ' ', 1200),
|
||||
...joined('sg65-j7', null, null, 1400),
|
||||
node('sg65-log', 'Log', 1600, 400),
|
||||
node('sg65-marker', 'OnSongMarker', 0, 800, { song: id }),
|
||||
node('sg65-jm', 'Join', 200, 800, { a: 'marker ' }),
|
||||
node('sg65-logm', 'Log', 400, 800),
|
||||
],
|
||||
edges: [
|
||||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||||
edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'),
|
||||
edge('sg65-b1', 'sg65-beat', 'bar', 'sg65-j1', 'b'),
|
||||
edge('sg65-b2', 'sg65-j1', 'text', 'sg65-j2', 'a'),
|
||||
edge('sg65-b3', 'sg65-j2', 'text', 'sg65-j3', 'a'),
|
||||
edge('sg65-b4', 'sg65-beat', 'beat', 'sg65-j3', 'b'),
|
||||
edge('sg65-b5', 'sg65-j3', 'text', 'sg65-j4', 'a'),
|
||||
edge('sg65-b6', 'sg65-j4', 'text', 'sg65-j5', 'a'),
|
||||
edge('sg65-b7', 'sg65-beat', 'late', 'sg65-j5', 'b'),
|
||||
edge('sg65-b8', 'sg65-j5', 'text', 'sg65-j6', 'a'),
|
||||
edge('sg65-b9', 'sg65-j6', 'text', 'sg65-j7', 'a'),
|
||||
edge('sg65-b10', 'sg65-pos', 'fraction', 'sg65-j7', 'b'),
|
||||
edge('sg65-b11', 'sg65-beat', 'exec', 'sg65-log', 'exec'),
|
||||
edge('sg65-b12', 'sg65-j7', 'text', 'sg65-log', 'message'),
|
||||
edge('sg65-m1', 'sg65-marker', 'name', 'sg65-jm', 'b'),
|
||||
edge('sg65-m2', 'sg65-marker', 'exec', 'sg65-logm', 'exec'),
|
||||
edge('sg65-m3', 'sg65-jm', 'text', 'sg65-logm', 'message'),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1004,6 +1038,34 @@ export async function song(t) {
|
||||
resumed === 'running',
|
||||
JSON.stringify({ heldClock, heldLater, resumed }),
|
||||
);
|
||||
// 65k2. The game follows the song (#338): On beat runs on every beat as it is heard, 0.5 s apart
|
||||
// at 120 BPM, bar 2 again after bar 2's last beat as the loop goes round; On song marker
|
||||
// runs each time round; the pause above held them with the clock. "late" is how long ago
|
||||
// the beat was heard, and Song position, read then, stands that far into the beat.
|
||||
const heardLines = async () =>
|
||||
(await json('__gm.console.lines()'))
|
||||
.map((line) => line.text)
|
||||
.filter((text) => /(beat|marker) /.test(text));
|
||||
const heardSome = await waitFor(
|
||||
heardLines,
|
||||
(lines) => lines.filter((l) => l.includes('marker Round')).length >= 2,
|
||||
80,
|
||||
);
|
||||
const beats = heardSome.flatMap((text) => {
|
||||
const m = /beat (\d+)\.(\d+) ([-\d.e]+) ([-\d.e]+)/.exec(text);
|
||||
return m ? [{ bar: +m[1], beat: +m[2], late: +m[3], fraction: +m[4] }] : [];
|
||||
});
|
||||
const counted = beats.map((b) => `${b.bar}.${b.beat}`);
|
||||
const expected = ['1.1', '1.2', '1.3', '1.4', '2.1', '2.2', '2.3', '2.4', '2.1', '2.2', '2.3', '2.4'];
|
||||
const markersHeard = heardSome.filter((text) => text.includes('marker Round'));
|
||||
check(
|
||||
'On beat runs on every beat of the song as heard, round the loop again from bar 2, none lost to the pause; On song marker each round',
|
||||
counted.length >= 9 &&
|
||||
counted.every((c, i) => c === expected[i]) &&
|
||||
markersHeard.length >= 2 &&
|
||||
beats.every((b) => b.late >= 0 && b.late <= 0.15 && Math.abs(b.fraction * 0.5 - b.late) < 0.02),
|
||||
JSON.stringify({ beats, markersHeard: markersHeard.length }),
|
||||
);
|
||||
await press('s');
|
||||
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
|
||||
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
|
||||
|
||||
Reference in new issue
Block a user