Files
feuerwerk/tests/editor/agent-music.test.ts
T
adminandClaude Opus 5.5 56425fca6a
feuerwerk / gate (push) Successful in 5m22s
feuerwerk / suite (push) Failing after 19m54s
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>
2026-10-19 18:44:58 +02:00

695 lines
31 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.
import { afterEach, describe, expect, it } from 'vitest';
import { CreateSoundCommand } from '../../src/core/commands/assetCommands';
import type {
BehaviorAsset,
CompositionAsset,
PatternAsset,
SongAsset,
SoundAsset,
SoundFxAsset,
} from '../../src/core/model/Asset';
import { setSoundMeasure } from '../../src/core/model/composition';
import { ROUTING_ID } from '../../src/core/model/Project';
import { settleRouting } from '../../src/core/music/projectRouting';
import { TO_COMPOSITION_BUS } from '../../src/core/properties/buses';
import { runTool } from '../../src/editor/agent/agentTools';
import { graphEditor, makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** The commands of #429 stage 5d: patterns, songs, sends, clips of sounds and compositions. */
const FILE = `${'a7'.repeat(32)}.wav`;
const run = async (fixture: SceneFixture, steps: object[], follows = false) => {
const { host, shown } = hostFor(fixture, null, follows);
const result = await runTool('run_commands', { steps }, host);
return { result, shown, text: text(result) };
};
const read = async (fixture: SceneFixture, asset: string) =>
(await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record<string, unknown>;
const graph = async (fixture: SceneFixture, asset: string) =>
(await runTool('get_graph', { asset }, hostFor(fixture).host)).structuredContent as {
nodes: { id: string; type: string; params: Record<string, unknown> }[];
edges: { id: string; from: { node: string }; to: { node: string }; send?: object }[];
};
const byId = <T>(fixture: SceneFixture, id: string) => fixture.project.assets.find((a) => a.id === id) as T;
/** Makes the assets by name and gives their ids. */
async function made<K extends string>(
fixture: SceneFixture,
assets: Record<K, string>,
): Promise<Record<K, string>> {
const steps = Object.entries(assets).map(([name, type]) => ({
command: 'create_asset',
args: { type, name },
as: name,
}));
const done = await run(fixture, steps);
expect(done.result.isError).toBeUndefined();
return Object.fromEntries(
Object.keys(assets).map((name) => [name, fixture.project.assets.find((a) => a.name === name)!.id]),
) as Record<K, string>;
}
function addSound(fixture: SceneFixture, name = 'Blip'): string {
const command = new CreateSoundCommand(name, FILE);
fixture.stack.execute(command);
return command.asset.id;
}
afterEach(() => setSoundMeasure(() => null));
describe('patterns', () => {
it('adds notes by number and by name, reads them back with their names and refuses a note past the end', async () => {
const fixture = makeSpriteScene();
const { Riff } = await made(fixture, { Riff: 'pattern' });
const { result, shown } = await run(
fixture,
[
{
command: 'add_notes',
args: {
asset: Riff,
notes: [
{ tick: 0, length: 480, note: 'C4' },
{ tick: 480, length: 240, note: 64, velocity: 0.5 },
{ tick: 960, length: 480, note: 'f#3' },
],
},
},
],
true,
);
expect(result.isError).toBeUndefined();
expect(shown).toEqual([{ kind: 'asset', asset: Riff, open: true }]);
const asset = await read(fixture, Riff);
expect(asset.ticks).toEqual({ perQuarter: 480, length: 7680 });
expect(
(asset.notes as Record<string, unknown>[]).map(({ id: _, ...rest }: Record<string, unknown>) => rest),
).toEqual([
{ tick: 0, length: 480, note: 60, velocity: 0.8, name: 'C4' },
{ tick: 480, length: 240, note: 64, velocity: 0.5, name: 'E4' },
{ tick: 960, length: 480, note: 54, velocity: 0.8, name: 'F#3' },
]);
const past = await run(fixture, [
{ command: 'add_notes', args: { asset: Riff, notes: [{ tick: 7680, length: 10, note: 60 }] } },
]);
expect(past.result.isError).toBe(true);
expect(past.text).toContain('7680 ticks long');
const flat = await run(fixture, [
{ command: 'add_notes', args: { asset: Riff, notes: [{ tick: 0, length: 10, note: 'Hb4' }] } },
]);
expect(flat.text).toContain('"Hb4" is no note');
expect(byId<PatternAsset>(fixture, Riff).notes).toHaveLength(3);
});
it('changes and removes notes by id, replaces all, and sets and clears the loop', async () => {
const fixture = makeSpriteScene();
const { Riff } = await made(fixture, { Riff: 'pattern' });
const added = await run(fixture, [
{ command: 'add_notes', args: { asset: Riff, notes: [{ tick: 0, length: 120, note: 60 }] }, as: 'n' },
{ command: 'change_notes', args: { asset: Riff, notes: [{ id: '$n', note: 'G4', tick: 240 }] } },
{ command: 'add_notes', args: { asset: Riff, notes: [{ tick: 0, length: 60, note: 48 }] }, as: 'm' },
{ command: 'remove_notes', args: { asset: Riff, notes: ['$m'] } },
{ command: 'set_loop', args: { asset: Riff, start: 0, end: 1920 } },
]);
expect(added.result.isError).toBeUndefined();
const pattern = byId<PatternAsset>(fixture, Riff);
expect(pattern.notes.map(({ tick, note }) => [tick, note])).toEqual([[240, 67]]);
expect(pattern.loop).toEqual({ start: 0, end: 1920 });
const replaced = await run(fixture, [
{
command: 'add_notes',
args: { asset: Riff, notes: [{ tick: 0, length: 1, note: 0 }], replace: true },
},
{ command: 'set_loop', args: { asset: Riff } },
]);
expect(replaced.result.isError).toBeUndefined();
expect(byId<PatternAsset>(fixture, Riff).notes.map((n) => n.note)).toEqual([0]);
expect(byId<PatternAsset>(fixture, Riff).loop).toBeUndefined();
const half = await run(fixture, [{ command: 'set_loop', args: { asset: Riff, start: 0 } }]);
expect(half.text).toContain('Give start and end');
});
});
describe('songs', () => {
it('lays a pattern as long as it is, a sound by its loaded length, and refuses one not loaded without a length', async () => {
const fixture = makeSpriteScene();
const { Theme, Riff } = await made(fixture, { Theme: 'song', Riff: 'pattern' });
const blip = addSound(fixture);
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const unknown = await run(fixture, [
{ command: 'add_song_item', args: { asset: Theme, track, play: blip, tick: 0 } },
]);
expect(unknown.result.isError).toBe(true);
expect(unknown.text).toContain('The length of Blip is not known yet');
expect(unknown.text).toContain('a bar of this song is 1920 ticks');
setSoundMeasure(() => 1.25);
const laid = await run(fixture, [
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0, transpose: 12 } },
{ command: 'add_song_item', args: { asset: Theme, track, play: blip, tick: 7680, volume: 6 } },
]);
expect(laid.result.isError).toBeUndefined();
// 1.25 s at 120 BPM is 2.5 quarters.
expect(
song().items.map(({ kind, tick, length, transpose, volume }) => [
kind,
tick,
length,
transpose,
volume,
]),
).toEqual([
['pattern', 0, 7680, 12, 0],
['sound', 7680, 1200, 0, 6],
]);
expect((await read(fixture, blip)).seconds).toBe(1.25);
const sound = song().items[1]!.id;
const wrong = await run(fixture, [
{ command: 'change_song_item', args: { asset: Theme, item: sound, transpose: 2 } },
]);
expect(wrong.text).toContain('Only a pattern item is transposed');
const loud = await run(fixture, [
{ command: 'change_song_item', args: { asset: Theme, item: song().items[0]!.id, volume: 3 } },
]);
expect(loud.text).toContain('from -60 to 0');
});
it('adds, changes, moves and removes tracks, keeps the last one, and the routing follows', async () => {
const fixture = makeSpriteScene();
const { Theme, Riff, Verb } = await made(fixture, { Theme: 'song', Riff: 'pattern', Verb: 'soundfx' });
const song = () => byId<SongAsset>(fixture, Theme);
const first = song().tracks[0]!.id;
const done = await run(fixture, [
{ command: 'add_song_track', args: { asset: Theme, name: 'Drums', at: 0 }, as: 'drums' },
{
command: 'change_song_track',
args: { asset: Theme, track: '$drums', volume: -6, pan: -0.5, solo: true, fx: Verb },
},
{ command: 'add_song_item', args: { asset: Theme, track: '$drums', play: Riff, tick: 0 } },
{ command: 'move_song_track', args: { asset: Theme, track: '$drums', to: 1 } },
]);
expect(done.result.isError).toBeUndefined();
const drums = song().tracks[1]!;
expect([song().tracks[0]!.id, drums.name, drums.volume, drums.pan, drums.solo, drums.fx]).toEqual([
first,
'Drums',
-6,
-0.5,
true,
Verb,
]);
expect((await graph(fixture, Theme)).nodes).toHaveLength(3);
const removed = await run(fixture, [
{ command: 'remove_song_track', args: { asset: Theme, track: drums.id } },
]);
expect(removed.result.isError).toBeUndefined();
expect(song().items).toHaveLength(0);
expect((await graph(fixture, Theme)).nodes).toHaveLength(2);
const last = await run(fixture, [{ command: 'remove_song_track', args: { asset: Theme, track: first } }]);
expect(last.text).toContain('keeps at least one track');
const noFx = await run(fixture, [
{ command: 'change_song_track', args: { asset: Theme, track: first, fx: Riff } },
]);
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' });
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const set = await run(
fixture,
[
{
command: 'set_song_lane',
args: {
asset: Theme,
track,
target: 'volume',
points: [
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12, ease: 'linear' },
],
},
},
],
true,
);
expect(set.result.isError).toBeUndefined();
expect(set.shown).toEqual([{ kind: 'asset', asset: Theme, open: true }]);
// In tick order, two on one tick as given (a jump); linear is stored as no easing.
expect(song().tracks[0]!.automation).toEqual([
{
target: 'volume',
points: [
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12 },
],
},
]);
const lanes = (await read(fixture, Theme)).tracks as { automation?: unknown[] }[];
expect(lanes[0]!.automation).toHaveLength(1);
const point = (value: number, ease?: string) => [{ tick: 0, value, ...(ease && { ease }) }];
const refused = async (target: string, points: object[], extra: object = {}) =>
(
await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target, points, ...extra } },
])
).text;
expect(await refused('volume', point(13))).toContain('value at tick 0 has to be from -60 to 12');
expect(await refused('pan', point(-2))).toContain('from -1 to 1');
expect(await refused('volume', point(0, 'wobbly'))).toContain('"wobbly" is no easing');
expect(await refused('volume', point(0, 'curve:nope'))).toContain('nope is no Curve asset');
expect(await refused('loudness', point(0))).toContain('is no lane');
expect(await refused('volume', point(0), { min: 0, max: 1 })).toContain('Only a parameter lane');
expect(song().tracks[0]!.automation![0]!.points).toHaveLength(4);
const nothing = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'pan' } },
]);
expect(nothing.text).toContain('Give the points');
const cleared = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'volume', points: [] } },
]);
expect(cleared.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
});
it('adds a parameter lane only for a parameter on the track, round its default, and get_asset lists what a lane can move', async () => {
const fixture = makeSpriteScene();
const { Theme, Filter } = await made(fixture, { Theme: 'song', Filter: 'soundfx' });
const fx = byId<SoundFxAsset>(fixture, Filter);
const edit = graphEditor(fixture, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain');
const output = edit.addNode('SoundFxOutput');
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
edit.connect(input, 'out', gain, 'in');
edit.connect(input, 'cut', gain, 'gain');
edit.connect(gain, 'out', output, 'in');
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const lane = (args: object) => ({
command: 'set_song_lane',
args: { asset: Theme, track, target: 'param:cut', ...args },
});
const none = await run(fixture, [lane({})]);
expect(none.text).toContain('Nothing on Track 1 has a parameter to move');
expect(((await read(fixture, Theme)).tracks as object[])[0]).not.toHaveProperty('params');
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: Filter } }]);
expect(((await read(fixture, Theme)).tracks as { params?: object }[])[0]!.params).toEqual([
{ name: 'cut', default: 800 },
]);
const other = await run(fixture, [{ ...lane({}), args: { asset: Theme, track, target: 'param:tone' } }]);
expect(other.text).toContain('Nothing on Track 1 has a parameter tone – it has cut');
// Without a range it gets one round the default, and keeps it without a point.
const added = await run(fixture, [lane({})]);
expect(added.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toEqual([{ target: 'param:cut', points: [], min: 0, max: 2000 }]);
const pointed = await run(fixture, [lane({ points: [{ tick: 0, value: 1500 }] })]);
expect(pointed.result.isError).toBeUndefined();
const over = await run(fixture, [lane({ points: [{ tick: 0, value: 2500 }] })]);
expect(over.text).toContain('from 0 to 2000');
// A narrower range holds the points it had.
await run(fixture, [lane({ min: 100, max: 1000 })]);
expect(song().tracks[0]!.automation).toEqual([
{ target: 'param:cut', points: [{ tick: 0, value: 1000 }], min: 100, max: 1000 },
]);
expect((await run(fixture, [lane({ min: 5 })])).text).toContain('Give min and max');
expect((await run(fixture, [lane({ min: 5, max: 5 })])).text).toContain('max has to be above min');
// A lane there is stays settable when its parameter is gone from the track.
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: '' } }]);
expect((await run(fixture, [lane({ min: 0, max: 10 })])).result.isError).toBeUndefined();
expect((await run(fixture, [lane({ remove: true, min: 0 })])).text).toContain('remove takes no points');
const removed = await run(fixture, [lane({ remove: true })]);
expect(removed.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
expect((await run(fixture, [lane({ remove: true })])).text).toContain('Track 1 has no param:cut lane');
});
it('sets the send of a song link and of a master routing link, where a sound only sends post', async () => {
const fixture = makeSpriteScene();
const { Theme } = await made(fixture, { Theme: 'song' });
const link = (await graph(fixture, Theme)).edges[0]!.id;
const sent = await run(fixture, [
{ command: 'set_send', args: { asset: Theme, edge: link, volume: -12, tap: 'preFx' } },
]);
expect(sent.result.isError).toBeUndefined();
expect((await graph(fixture, Theme)).edges[0]!.send).toEqual({ volume: -12, tap: 'preFx' });
// The editor settles the master routing after every edit; the test host does not, so it is done here.
const blip = addSound(fixture);
const { Music } = await made(fixture, { Music: 'bus' });
settleRouting(fixture.project);
const master = fixture.project.routing;
// A routed asset's node goes by the asset's id.
const linkOf = (asset: string) => master.edges.find((e) => e.from.node === asset)!;
const routed = await run(fixture, [
{ command: 'set_send', args: { asset: ROUTING_ID, edge: linkOf(blip).id, volume: -3, tap: 'preFx' } },
{ command: 'set_send', args: { asset: ROUTING_ID, edge: linkOf(Music).id, tap: 'postFx' } },
]);
expect(routed.result.isError).toBeUndefined();
expect([linkOf(blip).send, linkOf(Music).send]).toEqual([
{ volume: -3, tap: 'post' },
{ volume: 0, tap: 'postFx' },
]);
const blipLink = linkOf(blip).id;
const nowhere = await run(fixture, [{ command: 'set_send', args: { asset: blip, edge: blipLink } }]);
expect(nowhere.text).toContain('is no song and no master routing');
});
});
describe('clips of sounds', () => {
it('adds a clip with a name and its cut, changes it, lists its rows and removes it', async () => {
const fixture = makeSpriteScene();
const blip = addSound(fixture);
const done = await run(fixture, [
{
command: 'add_clip',
args: { asset: blip, name: 'Hit', props: { start: 0.25, end: 0.5 } },
as: 'clip',
},
{ command: 'change_clip', args: { asset: blip, clip: '$clip', props: { reverse: true } } },
]);
expect(done.result.isError).toBeUndefined();
const sound = byId<SoundAsset>(fixture, blip);
expect(sound.clips!.map(({ name, props }) => [name, props])).toEqual([
['Hit', { start: 0.25, end: 0.5, reverse: true }],
]);
const rows = (await read(fixture, blip)).clipProperties as { key: string }[];
expect(rows.map((r) => r.key)).toContain('reverse');
const bad = await run(fixture, [
{ command: 'change_clip', args: { asset: blip, clip: sound.clips![0]!.id, props: { start: -1 } } },
]);
expect(bad.result.isError).toBe(true);
const gone = await run(fixture, [
{ command: 'remove_clip', args: { asset: blip, clip: sound.clips![0]!.id } },
]);
expect(gone.result.isError).toBeUndefined();
expect(byId<SoundAsset>(fixture, blip).clips).toEqual([]);
});
});
describe('compositions', () => {
it('lays an object, a picture and a pattern on tracks, each as long as the rules say', async () => {
const fixture = makeSpriteScene();
const { Cut, Riff, Art, Slide } = await made(fixture, {
Cut: 'composition',
Riff: 'pattern',
Art: 'pixelart',
Slide: 'animation',
});
const comp = () => byId<CompositionAsset>(fixture, Cut);
const track = comp().tracks[0]!.id;
const { result, shown } = await run(
fixture,
[
{ command: 'add_composition_track', args: { asset: Cut, name: 'Music' }, as: 'music' },
{
command: 'add_composition_item',
args: { asset: Cut, track, place: fixture.spriteId, start: 0 },
as: 'box',
},
{ command: 'add_composition_item', args: { asset: Cut, track, place: Art, start: 30, length: 45 } },
{ command: 'add_composition_item', args: { asset: Cut, track: '$music', place: Riff, start: 0 } },
],
true,
);
expect(result.isError).toBeUndefined();
expect(shown.at(-1)).toEqual({ kind: 'asset', asset: Cut, open: true });
const [box, art, riff] = comp().items;
// 3 s at 60 fps; a pattern of 4 bars at 120 BPM is 8 s.
expect([box!.length, box!.fitMedia, art!.length, art!.fitMedia, riff!.length]).toEqual([
180,
true,
45,
undefined,
480,
]);
expect(riff!.audio).toEqual({ kind: 'pattern', asset: Riff, volume: 0, bus: TO_COMPOSITION_BUS });
expect(comp().objects.map((o) => o.id)).toEqual([box!.id, art!.id]);
const wearer = fixture.project.assets.find((a) => a.id === comp().objects[1]!.assetId)!;
expect([wearer.type, wearer.owner, wearer.props.texture]).toEqual(['sprite', Cut, Art]);
const animation = await run(fixture, [
{ command: 'add_composition_item', args: { asset: Cut, track, place: Slide, start: 0 } },
]);
expect(animation.text).toContain('use add_composition_clip');
const nested = await run(fixture, [
{ command: 'add_composition_item', args: { asset: Cut, track, place: Cut, start: 0 } },
]);
expect(nested.result.isError).toBe(true);
});
it('moves an item with its clip, refuses a clip over another, fades only what sounds, and removes with the object', async () => {
const fixture = makeSpriteScene();
const { Cut, Riff } = await made(fixture, { Cut: 'composition', Riff: 'pattern' });
const anim = await run(fixture, [
{ command: 'create_asset', args: { type: 'animation', name: 'Slide' }, as: 'anim' },
{ command: 'set_animation_root', args: { asset: '$anim', root: fixture.spriteId } },
{ command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' },
{ command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 0, value: 0 } },
{ command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 30, value: 100 } },
]);
expect(anim.result.isError).toBeUndefined();
const slide = fixture.project.assets.find((a) => a.name === 'Slide')!.id;
const comp = () => byId<CompositionAsset>(fixture, Cut);
const track = comp().tracks[0]!.id;
const laid = await run(fixture, [
{
command: 'add_composition_item',
args: { asset: Cut, track, place: fixture.spriteId, start: 0, length: 120 },
as: 'box',
},
{
command: 'add_composition_clip',
args: { asset: Cut, track, animation: slide, start: 10 },
as: 'clip',
},
{ command: 'add_composition_item', args: { asset: Cut, track, place: Riff, start: 200 }, as: 'riff' },
]);
expect(laid.result.isError).toBeUndefined();
const [box, riff] = comp().items;
const over = await run(fixture, [
{ command: 'add_composition_clip', args: { asset: Cut, track, animation: slide, start: 30 } },
]);
expect(over.text).toContain('would lie over the one at 10');
const moved = await run(fixture, [
{ command: 'change_composition_item', args: { asset: Cut, item: box!.id, start: 50 } },
{
command: 'change_composition_item',
args: { asset: Cut, item: riff!.id, fadeIn: 20, volume: -6, bus: '' },
},
]);
expect(moved.result.isError).toBeUndefined();
expect([comp().items[0]!.start, comp().clips[0]!.start]).toEqual([50, 60]);
expect(comp().items[1]).toMatchObject({ fadeIn: 20, audio: { volume: -6, bus: '' } });
const silent = await run(fixture, [
{ command: 'change_composition_item', args: { asset: Cut, item: box!.id, fadeOut: 5 } },
]);
expect(silent.text).toContain('Only an item with a sound fades');
const noBus = await run(fixture, [
{ command: 'change_composition_item', args: { asset: Cut, item: box!.id, volume: -3 } },
]);
expect(noBus.text).toContain('Only an audio item');
const gone = await run(fixture, [
{ command: 'remove_composition_items', args: { asset: Cut, items: [box!.id] } },
]);
expect(gone.result.isError).toBeUndefined();
expect([comp().items.map((i) => i.id), comp().objects.length, comp().clips.map((c) => c.id)]).toEqual([
[riff!.id],
0,
[],
]);
});
it('adds, renames, moves and removes tracks, and keeps the last one', async () => {
const fixture = makeSpriteScene();
const { Cut } = await made(fixture, { Cut: 'composition' });
const comp = () => byId<CompositionAsset>(fixture, Cut);
const first = comp().tracks[0]!.id;
const done = await run(fixture, [
{ command: 'add_composition_track', args: { asset: Cut, at: 0 }, as: 't' },
{ command: 'change_composition_track', args: { asset: Cut, track: '$t', name: 'Front' } },
{ command: 'move_composition_track', args: { asset: Cut, track: '$t', to: 5 } },
]);
expect(done.result.isError).toBeUndefined();
expect(comp().tracks.map((t) => [t.id === first, t.name])).toEqual([
[true, 'Track 1'],
[false, 'Front'],
]);
const removed = await run(fixture, [
{ command: 'remove_composition_track', args: { asset: Cut, track: first } },
]);
expect(removed.result.isError).toBeUndefined();
const last = await run(fixture, [
{ command: 'remove_composition_track', args: { asset: Cut, track: comp().tracks[0]!.id } },
]);
expect(last.text).toContain('keeps at least one track');
});
});
describe('silent music', () => {
it('get_asset says what plays nothing for want of an instrument, until it has one', async () => {
const fixture = makeSpriteScene();
const { Riff, Theme, Cut, Lead } = await made(fixture, {
Riff: 'pattern',
Theme: 'song',
Cut: 'composition',
Lead: 'instrument',
});
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
const built = await run(fixture, [
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0 }, as: 'item' },
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
]);
expect(built.result.isError).toBeUndefined();
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
expect((await read(fixture, Riff)).silent).toEqual([
'It has no instrument and plays nothing: set the property instrument of the pattern to an Instrument asset.',
]);
expect((await read(fixture, Theme)).silent).toEqual([expect.stringContaining(`Item ${item} plays Riff`)]);
expect((await read(fixture, Cut)).silent).toEqual([expect.stringContaining('which is not all heard')]);
// The item's own instrument is enough for the song; the pattern's for all of them.
await run(fixture, [{ command: 'change_song_item', args: { asset: Theme, item, instrument: Lead } }]);
expect((await read(fixture, Theme)).silent).toBeUndefined();
expect((await read(fixture, Riff)).silent).toHaveLength(1);
await run(fixture, [
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
]);
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
});
it('names the notes outside what the instrument sounds, after the transpose, until its range takes them in', async () => {
const fixture = makeSpriteScene();
const { Riff, Theme, Cut, Lead } = await made(fixture, {
Riff: 'pattern',
Theme: 'song',
Cut: 'composition',
Lead: 'instrument',
});
const node = (type: string) =>
(
fixture.project.assets.find((a) => a.id === Lead) as {
graph: { nodes: { id: string; type: string }[] };
}
).graph.nodes.find((n) => n.type === type)!.id;
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
const built = await run(fixture, [
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
{
command: 'add_notes',
args: {
asset: Riff,
notes: [
{ tick: 0, length: 480, note: 'C4' },
{ tick: 480, length: 480, note: 'C5' },
],
},
},
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0, transpose: -12 } },
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
]);
expect(built.result.isError).toBeUndefined();
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
// A new instrument's Sample waits for its sound: no note is heard, and get_graph says why.
expect((await read(fixture, Riff)).silent).toEqual([
'C4, C5 are not heard: Lead sounds no note at all. To hear them, get_graph on it shows what it lacks, like the sound of a Sample, or move the notes.',
]);
const sound = addSound(fixture);
await run(fixture, [
{
command: 'set_node_param',
args: { asset: Lead, node: node('InstrumentSample'), key: 'sound', value: sound },
},
]);
expect((await read(fixture, Riff)).silent).toEqual([
'C5 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or move the notes.',
]);
// An octave down, C5 comes in and C4 drops out: the song goes by the notes as they sound.
expect((await read(fixture, Theme)).silent).toEqual([
`Item ${item} plays Riff, transposed by -12. C3 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or change the transpose.`,
]);
expect((await read(fixture, Cut)).silent).toEqual([
expect.stringContaining('Riff, which is not all heard'),
]);
const notes = node('InstrumentNotes');
await run(fixture, [
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'from', value: '48' } },
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'to', value: '72' } },
]);
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
});
});