Files
feuerwerk/tests/core/song-markers.test.ts
T
adminandClaude Opus 5.5 a6a41b6b69
feuerwerk / gate (push) Failing after 15m41s
feuerwerk / suite (push) Canceled after 18m24s
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>
2026-10-29 19:48:51 +01:00

133 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, DuplicateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { SongAsset } from '../../src/core/model/Asset';
import {
changedMarker,
heldSongMarkers,
newMarker,
nextMarkerName,
withoutMarker,
} from '../../src/core/music/markers';
import { assetRefLabel, findSongMarker, markerChoices } from '../../src/core/properties/assetRef';
import { makeProject, roundTrip } from '../helpers';
function makeSong() {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
f.stack.execute(create);
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
return { ...f, song };
}
describe('song markers (#338)', () => {
it('holds markers to whole ticks from 0, a name and ids of their own, in the order of their ticks', () => {
const held = heldSongMarkers([
{ id: 'b', name: ' Boss ', tick: 3840.4 },
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'a', name: '', tick: 1920 },
{ id: 'c', name: 'Before', tick: -1 },
{ id: 'd', name: 'Nowhere' },
'no marker',
null,
]);
expect(held.map((m) => [m.name, m.tick])).toEqual([
['Intro', 0],
['Marker 3', 1920],
['Boss', 3840],
]);
expect(new Set(held.map((m) => m.id)).size).toBe(3);
expect(held.find((m) => m.tick === 1920)!.id).not.toBe('a');
expect(heldSongMarkers(undefined)).toEqual([]);
});
it('keeps markers on one tick in the order they came', () => {
const held = heldSongMarkers([
{ id: 'x', name: 'Second', tick: 960 },
{ id: 'y', name: 'First', tick: 0 },
{ id: 'z', name: 'Third', tick: 960 },
]);
expect(held.map((m) => m.name)).toEqual(['First', 'Second', 'Third']);
});
it('names a new marker by the first number not taken', () => {
expect(nextMarkerName([])).toBe('Marker 1');
const markers = heldSongMarkers([
{ name: 'Marker 1', tick: 0 },
{ name: 'Marker 3', tick: 10 },
]);
expect(nextMarkerName(markers)).toBe('Marker 2');
const made = newMarker(markers, 479.6);
expect(made).toMatchObject({ name: 'Marker 2', tick: 480 });
expect(newMarker(markers, -5, ' Drop ')).toMatchObject({ name: 'Drop', tick: 0 });
});
it('changes or takes away one marker and leaves the others', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'A', tick: 0 },
{ id: 'b', name: 'B', tick: 960 },
]);
expect(changedMarker(markers, 'b', { tick: 1920 })).toEqual([markers[0], { ...markers[1], tick: 1920 }]);
expect(changedMarker(markers, 'a', { name: 'Start' })[0]).toEqual({ id: 'a', name: 'Start', tick: 0 });
expect(withoutMarker(markers, 'a')).toEqual([markers[1]]);
});
it('writes the markers as one step, leaves the key out without one and takes it back on undo', () => {
const f = makeSong();
expect(f.song().markers).toBeUndefined();
const one = [{ id: 'm', name: 'Drop', tick: 1920.2 }];
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(true);
f.stack.execute(new SetSongMarkersCommand(f.song().id, one, 'Add marker'));
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(false);
f.stack.execute(new SetSongMarkersCommand(f.song().id, [], 'Delete marker'));
expect('markers' in f.song()).toBe(false);
f.stack.undo();
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
f.stack.undo();
expect('markers' in f.song()).toBe(false);
});
it('keeps the markers through saving and loading', () => {
const f = makeSong();
const markers = [
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'b', name: 'Boss', tick: 7680 },
];
f.stack.execute(new SetSongMarkersCommand(f.song().id, markers));
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect(back.markers).toEqual(markers);
const none = makeSong();
const plain = roundTrip(none.project).assets.find((a) => a.id === none.song().id) as SongAsset;
expect('markers' in plain).toBe(false);
});
it('offers the markers of every song to a marker field, each read "Song / Marker"', () => {
const f = makeSong();
f.stack.execute(
new SetSongMarkersCommand(f.song().id, [
{ id: 'm2', name: 'Boss', tick: 1920 },
{ id: 'm1', name: 'Intro', tick: 0 },
]),
);
expect(markerChoices(f.project)).toEqual([
{ id: 'm1', label: 'Tune / Intro' },
{ id: 'm2', label: 'Tune / Boss' },
]);
expect(assetRefLabel(f.project, 'm2')).toBe('Tune / Boss');
expect(findSongMarker(f.project, 'm1')?.song.id).toBe(f.song().id);
expect(assetRefLabel(f.project, 'gone')).toBe('gone?');
});
it('gives the markers of a copy of a song ids of their own, so a node names one of them', () => {
const f = makeSong();
f.stack.execute(new SetSongMarkersCommand(f.song().id, [{ id: 'm1', name: 'Intro', tick: 0 }]));
const duplicate = new DuplicateAssetCommand(f.song().id);
f.stack.execute(duplicate);
const copy = f.project.assets.find((a) => a.id === duplicate.copyId) as SongAsset;
expect(copy.markers?.map((m) => m.name)).toEqual(['Intro']);
expect(copy.markers?.[0]?.id).not.toBe('m1');
expect(findSongMarker(f.project, 'm1')?.song.id).toBe(f.song().id);
});
});