The agent could already draw, animate and wire graphs, but songs, patterns, sound clips and compositions were out of its reach. This last step of #429 adds the two missing catalog areas, so the agent can now use 168 of the editor's commands. - Area music (15 commands): notes, pattern and song loops, song tracks and items, sends, and clips of sounds and videos. - Area composition (9 commands): tracks, items for objects, images and audio, changes with fades, volume and bus, removal, and animation clips. - A pitch is a MIDI number or a name like C4 or F#3. A note past the end of its pattern is refused. - Song and master routing stay graphs. The agent links them with connect, and set_send sets level and tap. get_graph now shows the send of every link. - Lengths follow the same rules as dragging in the tab. A sound has a length only after the editor loaded it. get_asset reports it as seconds, and without it the agent has to give a length. - Moving a composition item takes its clips along, as dragging does. A clip that would then overlap another one is refused. Fades are only allowed on items with sound. - get_asset reads notes back with their names and gives the length of a pattern in ticks. Found while testing live: two song tracks in the same column made lint_graph report a backwards link. arrange_graph cleared it, as the agent instructions ask. Tests: - agent-music (new, 9 tests) and the other agent and relay tests: 109/109. - 9 mutations of the new code, each one red in its own test. - e2e 71 on melektaus-atx: 25/25. New check: notes are read back by name, and a composition with a pattern and a sprite opens in its tab. - Gate on melektaus-atx: CHECK EXIT 0, 323 files, 4987 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
18 KiB
TypeScript
426 lines
18 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
|
import type { CompositionAsset, PatternAsset, SongAsset, SoundAsset } 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 { 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('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');
|
|
});
|
|
});
|