Let a song carry named markers on a strip in the song tab (#338)
Stage 1 of #338. Game sync needs named places in a song: an event when the playhead passes one, and a switch that jumps to one. This stage adds the markers themselves. Games do nothing with them yet. - A song has markers, each with id, name and tick. A song without markers stores nothing for them. Two markers may share tick and name, since nodes will name them by id. - Markers change neither the sound nor the bake fingerprint. - The song tab draws a strip under the ruler. A marker has a green flag with its name and a faint line through the tracks. Tracks and lanes sit 18 px lower. The ruler of the pattern tab is unchanged. - Double click on an empty place adds a marker on the grid, with Alt or Shift on the exact tick. Dragging moves it in one undo step. - A click on a marker sets the start of playback there. A double click renames it in the name dialog of the hierarchy groups. - The context menu offers Play from here, Rename and Delete marker, on an empty place Add marker. A tooltip shows the full name. The wheel over the strip zooms time like the ruler. - New agent commands add_song_marker, change_song_marker and remove_song_marker. get_asset lists them under markers. Tests: - song-markers in core 6 and editor 4, 1 agent test, with the touched song, agent and MCP tests 175/175 - 9 counter-probes, all compile, all red - e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe where a dragged marker is not taken over: 65g3 red - gate on atx: CHECK EXIT 0, 332 files, 5093 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2099511695
commit
56425fca6a
13 files changed
+718
-30
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } 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 { 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);
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,45 @@ describe('songs', () => {
|
||||
expect(noFx.text).toContain('is no Sound FX asset');
|
||||
});
|
||||
|
||||
it('adds, renames, moves and removes markers of a song (#338), and get_asset lists them', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { Theme } = await made(fixture, { Theme: 'song' });
|
||||
const song = () => byId<SongAsset>(fixture, Theme);
|
||||
const done = await run(fixture, [
|
||||
{ command: 'add_song_marker', args: { asset: Theme, tick: 7680, name: 'Boss' }, as: 'boss' },
|
||||
{ command: 'add_song_marker', args: { asset: Theme, tick: 0 } },
|
||||
{ command: 'change_song_marker', args: { asset: Theme, marker: '$boss', tick: 3840 } },
|
||||
]);
|
||||
expect(done.result.isError).toBeUndefined();
|
||||
expect(song().markers!.map(({ name, tick }) => [name, tick])).toEqual([
|
||||
['Marker 1', 0],
|
||||
['Boss', 3840],
|
||||
]);
|
||||
const listed = (await read(fixture, Theme)).markers as { id: string; name: string; tick: number }[];
|
||||
expect(listed.map((m) => m.name)).toEqual(['Marker 1', 'Boss']);
|
||||
|
||||
const boss = song().markers![1]!.id;
|
||||
const blank = await run(fixture, [
|
||||
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: ' ' } },
|
||||
]);
|
||||
expect(blank.text).toContain('A marker needs a name');
|
||||
const renamed = await run(fixture, [
|
||||
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: 'Fight' } },
|
||||
]);
|
||||
expect(renamed.result.isError).toBeUndefined();
|
||||
expect(song().markers![1]).toEqual({ id: boss, name: 'Fight', tick: 3840 });
|
||||
|
||||
const gone = await run(fixture, [
|
||||
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||||
]);
|
||||
expect(gone.result.isError).toBeUndefined();
|
||||
expect(song().markers!.map((m) => m.name)).toEqual(['Marker 1']);
|
||||
const missing = await run(fixture, [
|
||||
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||||
]);
|
||||
expect(missing.text).toContain(`Theme has no marker ${boss}`);
|
||||
});
|
||||
|
||||
it('sets the volume lane of a track in tick order with its easings, refuses what is out of range, and clears it with []', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { Theme, Bend } = await made(fixture, { Theme: 'song', Bend: 'curve' });
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { heldSongMarkers } from '../../src/core/music/markers';
|
||||
import {
|
||||
MARKER_FLAG,
|
||||
MARKER_REACH,
|
||||
MARKER_STRIP,
|
||||
markerAt,
|
||||
markerNameX,
|
||||
markerSpans,
|
||||
SONG_TOP,
|
||||
} from '../../src/editor/song/markerView';
|
||||
import { RULER_HEIGHT } from '../../src/editor/music/timeView';
|
||||
|
||||
/** A tick a pixel, from x 100; every name 6 pixels a letter. */
|
||||
const VIEW = { panX: 100, pxPerTick: 1 };
|
||||
const measure = (name: string) => name.length * 6;
|
||||
|
||||
describe('the marker strip of the song tab (#338)', () => {
|
||||
it('lies between the ruler and the tracks', () => {
|
||||
expect(SONG_TOP).toBe(RULER_HEIGHT + MARKER_STRIP);
|
||||
});
|
||||
|
||||
it('draws a marker at its tick, as wide as its flag and name, but not into the next one', () => {
|
||||
const markers = heldSongMarkers([
|
||||
{ id: 'a', name: 'Intro', tick: 0 },
|
||||
{ id: 'b', name: 'A long name', tick: 40 },
|
||||
{ id: 'c', name: '', tick: 300 },
|
||||
]);
|
||||
const spans = markerSpans(VIEW, markers, measure);
|
||||
expect(spans.map((s) => s.x)).toEqual([100, 140, 400]);
|
||||
// Room to the next one is 40 pixels, less 2: the name of "Intro" (30) and the flag fit just so.
|
||||
expect(spans[0]!.w).toBe(38);
|
||||
expect(spans[1]!.w).toBe(MARKER_FLAG + 3 + measure('A long name'));
|
||||
expect(spans[2]!.w).toBe(MARKER_FLAG + 3 + measure('Marker 3'));
|
||||
expect(markerNameX(spans[0]!)).toBe(100 + MARKER_FLAG + 3);
|
||||
});
|
||||
|
||||
it('keeps the flag of a marker with another one close behind it', () => {
|
||||
const markers = heldSongMarkers([
|
||||
{ id: 'a', name: 'Close', tick: 0 },
|
||||
{ id: 'b', name: 'By', tick: 2 },
|
||||
]);
|
||||
expect(markerSpans(VIEW, markers, measure)[0]!.w).toBe(MARKER_FLAG);
|
||||
});
|
||||
|
||||
it('takes the marker near its line or on its flag and name, the later one where they overlap', () => {
|
||||
const markers = heldSongMarkers([
|
||||
{ id: 'a', name: 'Intro', tick: 0 },
|
||||
{ id: 'b', name: 'B', tick: 6 },
|
||||
]);
|
||||
const spans = markerSpans(VIEW, markers, measure);
|
||||
expect(markerAt(spans, 100 - MARKER_REACH)).toBe('a');
|
||||
expect(markerAt(spans, 100 - MARKER_REACH - 1)).toBeNull();
|
||||
expect(markerAt(spans, 103)).toBe('b');
|
||||
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 6)).toBe('b');
|
||||
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 7)).toBeNull();
|
||||
expect(markerAt([], 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user