Files
feuerwerk/tests/editor/agent-music.test.ts
T
adminandClaude Opus 5.5 10f5e3ba8f
feuerwerk / gate (push) Successful in 5m17s
feuerwerk / suite (push) Successful in 18m54s
Let the agent write music and build compositions (#429)
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>
2026-10-18 17:32:44 +02:00

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');
});
});