Let the agent write music and build compositions (#429)
feuerwerk / gate (push) Successful in 5m17s
feuerwerk / suite (push) Successful in 18m54s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 17:32:44 +02:00
1 parent 0684e76dc5
commit 10f5e3ba8f
7 files changed
+1449 -2

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
import { CommandError } from '../../../core/commands/Command';
import {
addCompositionItem,
removeCompositionTrack,
removeFromComposition,
SetCompositionCommand,
} from '../../../core/commands/compositionCommands';
import { addPictureItem, droppedPicture } from '../../../core/commands/compositionSprites';
import { requireComposition } from '../../../core/commands/require';
import { newId } from '../../../core/ids';
import type {
CompositionAsset,
CompositionClip,
CompositionItem,
CompositionTrack,
} from '../../../core/model/Asset';
import {
audioSeconds,
clipLength,
compositionFps,
fittedLength,
itemSounds,
newCompositionTrack,
} from '../../../core/model/composition';
import {
audioKindOf,
clipSpans,
droppedLength,
fadedItem,
movedWithClips,
resizedItem,
} from '../../../core/model/compositionEdit';
import { placementRejection } from '../../../core/model/placement';
import { findAsset, findAssetOf, type Project } from '../../../core/model/Project';
import { FADE_CURVES, type FadeCurve } from '../../../core/model/sound';
import { MASTER_BUS, TO_COMPOSITION_BUS } from '../../../core/properties/buses';
import { ITEM_MIN_DB, SOUND_ITEM_MAX_DB } from '../../../core/music/song';
import type { ArgSchema } from '../argCheck';
import {
type Args,
type CatalogCommand,
flag,
id,
ids,
number,
numberOf,
object,
oneOf,
text,
theTab,
whole,
} from './shared';
const COMPOSITION = id('Id of the composition asset.');
const TRACK = id('Id of the track, as get_asset lists it under tracks.');
const ITEM = id('Id of the item, as get_asset lists it under items.');
const CLIP = id('Id of the clip, as get_asset lists it under clips.');
const INDEX = whole('Place in the list of tracks, from 0 (the top one, drawn in front).', 0);
const FRAME = whole('Frames from the start, at the fps of the composition.', 0);
const CURVE = oneOf(
FADE_CURVES.map((c) => c.value),
'The shape of the fade.',
);
const BUS: ArgSchema = {
type: 'string',
description:
`Where an audio item is heard: "${TO_COMPOSITION_BUS}" into the bus of the composition, "" along the routing ` +
`of what it plays, "${MASTER_BUS}" the master, else the id of a bus.`,
};
/** The tracks, items and animation clips of Composition assets. */
export const COMPOSITION_COMMANDS: readonly CatalogCommand[] = [
{
name: 'add_composition_track',
description:
'Adds a track to a composition, at the bottom or at an index. The top track is drawn in front.',
args: object({ asset: COMPOSITION, name: id('Its name.'), at: INDEX }, ['asset']),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const track = newCompositionTrack(comp.tracks.length + 1);
if (typeof args.name === 'string') track.name = args.name;
const tracks = [...comp.tracks];
tracks.splice(Math.min((args.at as number | undefined) ?? tracks.length, tracks.length), 0, track);
return { command: new SetCompositionCommand(comp.id, { tracks }, 'Add track'), made: { id: track.id } };
},
shows: theTab,
},
{
name: 'change_composition_track',
description: 'Renames a track of a composition.',
args: object({ asset: COMPOSITION, track: TRACK, name: id('Its name.') }, ['asset', 'track', 'name']),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const track = requireTrack(comp, text(args, 'track'));
const tracks = comp.tracks.map((t): CompositionTrack =>
t === track ? { ...t, name: text(args, 'name') } : t,
);
return { command: new SetCompositionCommand(comp.id, { tracks }, 'Rename track') };
},
shows: theTab,
},
{
name: 'move_composition_track',
description: 'Moves a track of a composition to another place in its list; what lies on it goes along.',
args: object({ asset: COMPOSITION, track: TRACK, to: INDEX }, ['asset', 'track', 'to']),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const track = requireTrack(comp, text(args, 'track'));
const tracks = comp.tracks.filter((t) => t !== track);
tracks.splice(Math.min(numberOf(args, 'to'), tracks.length), 0, track);
return { command: new SetCompositionCommand(comp.id, { tracks }, 'Move track') };
},
shows: theTab,
},
{
name: 'remove_composition_track',
description:
'Removes a track of a composition with its items, their objects and its clips; the last track stays.',
args: object({ asset: COMPOSITION, track: TRACK }, ['asset', 'track']),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const track = requireTrack(comp, text(args, 'track'));
if (comp.tracks.length <= 1) throw new CommandError('A composition keeps at least one track');
return { command: removeCompositionTrack(project, comp.id, track.id) };
},
shows: theTab,
},
{
name: 'add_composition_item',
description:
'Lays an asset on a track of a composition, from a frame. A placeable asset (a sprite, a container, a text, ...) ' +
'becomes an object of the composition, living as long as the item; a texture, pixel art, vector art, a video or a ' +
'clip of one gets a sprite of the composition to wear it; a sound, a sound clip, a pattern or a song becomes an ' +
'audio item. Without a length it is as long as its video or what it plays, else 3 s, and keeps following it. ' +
"An item's object is moved and set with the commands of area objects, in = the composition. Items of a track may " +
'overlap. Animations go on a track with add_composition_clip.',
args: object(
{
asset: COMPOSITION,
track: TRACK,
place: id('Id of the asset (or video or sound clip) to lay on the track.'),
start: FRAME,
length: whole('Frames it lasts.', 1),
offset: number('Seconds into its video or what it plays it begins.'),
},
['asset', 'track', 'place', 'start'],
),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
requireTrack(comp, text(args, 'track'));
const placed = text(args, 'place');
const fits = typeof args.length !== 'number';
const item: Omit<CompositionItem, 'id'> = {
track: text(args, 'track'),
start: numberOf(args, 'start'),
length: 1,
offset: (args.offset as number | undefined) ?? 0,
...(fits ? { fitMedia: true } : {}),
};
const audio = audioKindOf(project, placed);
if (audio) {
if (
fits &&
audio === 'sound' &&
audioSeconds(project, { kind: audio, asset: placed, volume: 0, bus: '' }) <= 0
)
throw new CommandError(
`The length of ${findAsset(project, placed)?.name ?? placed} is not known yet – give a length ` +
`(${compositionFps(comp)} frames a second)`,
);
const added: CompositionItem = {
...item,
id: newId(),
audio: { kind: audio, asset: placed, volume: 0, bus: TO_COMPOSITION_BUS },
};
added.length = fits ? fittedLength(project, comp, added) : numberOf(args, 'length');
const items = [...comp.items, added];
return { command: new SetCompositionCommand(comp.id, { items }, 'Add item'), made: { id: added.id } };
}
const asset = findAsset(project, placed);
if (asset?.type === 'animation')
throw new CommandError('An animation goes on a track as a clip – use add_composition_clip');
const picture = droppedPicture(project, placed);
if (!picture) {
if (!asset) throw new CommandError(`There is no asset ${placed}`);
const rejection = placementRejection(project, comp, asset);
if (rejection) throw new CommandError(rejection);
}
item.length = fits ? droppedLength(project, comp, placed) : numberOf(args, 'length');
const { command, objectId } = picture
? addPictureItem(project, comp.id, picture, item)
: addCompositionItem(project, comp.id, placed, item);
return { command, made: { id: objectId } };
},
shows: theTab,
},
{
name: 'change_composition_item',
description:
'Changes an item of a composition. Moving it (start, track) takes the clips that belong to it along. A length ' +
'given is its own from then on; fitMedia true hands it back to its video or what it plays. Fades are in frames; ' +
'volume and bus are for audio items. Give only what changes.',
args: object(
{
asset: COMPOSITION,
item: ITEM,
track: TRACK,
start: FRAME,
length: whole('Frames it lasts.', 1),
fitMedia: flag('Its length follows its video or what it plays again.'),
offset: number('Seconds into its video or what it plays it begins; below 0 it waits that long.'),
fadeIn: whole('Frames its sound rises from silence at its start; 0 for none.', 0),
fadeOut: whole('Frames its sound falls back to silence at its end; 0 for none.', 0),
curveIn: CURVE,
curveOut: CURVE,
volume: { type: 'number', description: `In dB, from ${ITEM_MIN_DB} to ${SOUND_ITEM_MAX_DB}.` },
bus: BUS,
},
['asset', 'item'],
),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const item = requireItem(comp, text(args, 'item'));
let parts: { items: CompositionItem[]; clips?: CompositionClip[] } = { items: [...comp.items] };
if (typeof args.start === 'number' || typeof args.track === 'string') {
const trackIds = comp.tracks.map((t) => t.id);
const by =
typeof args.track === 'string'
? trackIds.indexOf(requireTrack(comp, args.track).id) - trackIds.indexOf(item.track)
: 0;
const moved = movedWithClips(
comp.items,
comp.clips,
new Set([item.id]),
trackIds,
typeof args.start === 'number' ? args.start - item.start : 0,
by,
(clip) => Math.max(1, clipLength(project, comp, clip)),
);
if (!moved)
throw new CommandError('A clip that goes along with the item would lie over another clip there');
parts = moved;
}
const at = parts.items.findIndex((i) => i.id === item.id);
parts.items[at] = changedItem(project, comp, parts.items[at]!, args);
return { command: new SetCompositionCommand(comp.id, parts, 'Change item') };
},
shows: theTab,
},
{
name: 'remove_composition_items',
description:
'Removes items of a composition with their objects and the clips that belong to them, and clips.',
args: object({ asset: COMPOSITION, items: ids('Ids of the items.'), clips: ids('Ids of the clips.') }, [
'asset',
]),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const items = (args.items ?? []) as string[];
const clips = (args.clips ?? []) as string[];
if (items.length + clips.length === 0) throw new CommandError('Name items or clips to remove');
for (const itemId of items) requireItem(comp, itemId);
for (const clipId of clips) requireClip(comp, clipId);
return { command: removeFromComposition(project, comp.id, items, clips) };
},
shows: theTab,
},
{
name: 'add_composition_clip',
description:
'Lays an animation on a track of a composition as a clip, one pass long, from a frame. It plays on every item ' +
'of its track it overlaps; its rows find their targets in the item as Play animation finds them. Clips of a ' +
'track never overlap.',
args: object(
{ asset: COMPOSITION, track: TRACK, animation: id('Id of the animation asset.'), start: FRAME },
['asset', 'track', 'animation', 'start'],
),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
requireTrack(comp, text(args, 'track'));
const animation = findAssetOf(project, text(args, 'animation'), 'animation');
if (!animation) throw new CommandError(`${text(args, 'animation')} is no animation asset`);
const clip: CompositionClip = {
id: newId(),
track: text(args, 'track'),
animation: animation.id,
start: numberOf(args, 'start'),
};
if (clipLength(project, comp, clip) === 0)
throw new CommandError(`${animation.name} has no frames to play here`);
requireRoom(project, comp, clip);
return {
command: new SetCompositionCommand(comp.id, { clips: [...comp.clips, clip] }, 'Add clip'),
made: { id: clip.id },
};
},
shows: theTab,
},
{
name: 'change_composition_clip',
description: 'Moves a clip of a composition to another frame or track, where it fits whole.',
args: object({ asset: COMPOSITION, clip: CLIP, track: TRACK, start: FRAME }, ['asset', 'clip']),
build: (args, { project }) => {
const comp = requireComposition(project, text(args, 'asset'));
const clip = requireClip(comp, text(args, 'clip'));
const moved: CompositionClip = {
...clip,
...(typeof args.track === 'string' ? { track: requireTrack(comp, args.track).id } : {}),
...(typeof args.start === 'number' ? { start: args.start } : {}),
};
requireRoom(project, comp, moved);
const clips = comp.clips.map((c) => (c === clip ? moved : c));
return { command: new SetCompositionCommand(comp.id, { clips }, 'Move clip') };
},
shows: theTab,
},
];
/** The item with what `args` change besides its place, refused where it asks for what the item cannot have. */
function changedItem(
project: Project,
comp: CompositionAsset,
item: CompositionItem,
args: Args,
): CompositionItem {
let out = item;
if (typeof args.offset === 'number') out = { ...out, offset: args.offset };
if (typeof args.length === 'number') out = resizedItem(out, args.length);
if (args.fitMedia === true) out = { ...out, fitMedia: true, length: fittedLength(project, comp, out) };
if (args.fitMedia === false) out = resizedItem(out, out.length);
const fades = ['fadeIn', 'fadeOut', 'curveIn', 'curveOut'].some((key) => args[key] !== undefined);
if (fades && !itemSounds(project, comp, out))
throw new CommandError(
'Only an item with a sound fades: an audio item, or one whose object wears a video',
);
if (typeof args.fadeIn === 'number') out = fadedItem(out, 'in', out.start + args.fadeIn);
if (typeof args.fadeOut === 'number') out = fadedItem(out, 'out', out.start + out.length - args.fadeOut);
if (typeof args.curveIn === 'string') out = { ...out, curveIn: args.curveIn as FadeCurve };
if (typeof args.curveOut === 'string') out = { ...out, curveOut: args.curveOut as FadeCurve };
if (args.volume !== undefined || args.bus !== undefined) {
if (!out.audio) throw new CommandError('Only an audio item has a volume and a bus');
const audio = { ...out.audio };
if (typeof args.volume === 'number') {
if (args.volume < ITEM_MIN_DB || args.volume > SOUND_ITEM_MAX_DB)
throw new CommandError(`The volume has to be from ${ITEM_MIN_DB} to ${SOUND_ITEM_MAX_DB}`);
audio.volume = args.volume;
}
if (typeof args.bus === 'string') audio.bus = busOf(project, args.bus);
out = { ...out, audio };
}
return out;
}
function busOf(project: Project, bus: string): string {
if (bus === '' || bus === TO_COMPOSITION_BUS || bus === MASTER_BUS) return bus;
if (findAsset(project, bus)?.type !== 'bus') throw new CommandError(`${bus} is no bus asset`);
return bus;
}
/** Refuses a clip that would lie over another clip of its track. */
function requireRoom(project: Project, comp: CompositionAsset, clip: CompositionClip): void {
const length = Math.max(1, clipLength(project, comp, clip));
const taken = clipSpans(comp.clips, clip.track, (c) => clipLength(project, comp, c), new Set([clip.id]));
const hit = taken.find((span) => span.start < clip.start + length && clip.start < span.start + span.length);
if (hit)
throw new CommandError(
`The clip (${length} frames from ${clip.start}) would lie over the one at ${hit.start}–${hit.start + hit.length}`,
);
}
function requireTrack(comp: CompositionAsset, trackId: string): CompositionTrack {
const track = comp.tracks.find((t) => t.id === trackId);
if (!track) throw new CommandError(`${comp.name} has no track ${trackId}`);
return track;
}
function requireItem(comp: CompositionAsset, itemId: string): CompositionItem {
const item = comp.items.find((i) => i.id === itemId);
if (!item) throw new CommandError(`${comp.name} has no item ${itemId}`);
return item;
}
function requireClip(comp: CompositionAsset, clipId: string): CompositionClip {
const clip = comp.clips.find((c) => c.id === clipId);
if (!clip) throw new CommandError(`${comp.name} has no clip ${clipId}`);
return clip;
}
+548
View File
@@ -0,0 +1,548 @@
import {
AddClipCommand,
RemoveClipCommand,
RenameClipCommand,
SetClipPropsCommand,
} from '../../../core/commands/clipCommands';
import { type Command, CommandError } from '../../../core/commands/Command';
import { oneStep } from '../../../core/commands/CompoundCommand';
import { SetLoopCommand, SetPatternNotesCommand } from '../../../core/commands/patternCommands';
import { requireAsset, requireEdge, requirePattern, requireSong } from '../../../core/commands/require';
import { SetSendCommand, SetSongCommand } from '../../../core/commands/songCommands';
import type { SendTap } from '../../../core/graph/Graph';
import { newId } from '../../../core/ids';
import { HIGHEST_NOTE, LOWEST_NOTE, noteName } from '../../../core/instrument/notes';
import type { PatternAsset, SongAsset } from '../../../core/model/Asset';
import { audioSeconds } from '../../../core/model/composition';
import type { PropertyBag } from '../../../core/model/Properties';
import {
findAsset,
findAssetOf,
findGraphAsset,
type Project,
ROUTING_ID,
} from '../../../core/model/Project';
import { clipsOf, findSound, hasClips } from '../../../core/model/sound';
import { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern';
import { SEND_TAPS } from '../../../core/music/routing';
import {
itemMaxDb,
ITEM_MIN_DB,
MAX_TRANSPOSE,
newItem,
newTrack,
type SongItem,
type SongTrack,
songMeter,
songTempo,
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../../core/music/song';
import { TICKS_PER_QUARTER, ticksOfSeconds, ticksPerBar } from '../../../core/music/time';
import { NO_FX } from '../../../core/properties/assetFx';
import type { ArgSchema } from '../argCheck';
import {
type Args,
type CatalogCommand,
flag,
id,
ids,
listOf,
number,
numberOf,
object,
oneOf,
text,
theTab,
whole,
} from './shared';
const PATTERN = id('Id of the pattern asset.');
const SONG = id('Id of the song asset.');
const TRACK = id('Id of the track, as get_asset lists it under tracks.');
const ITEM = id('Id of the item, as get_asset lists it under items.');
const INDEX = whole('Place in the list of tracks, from 0 (the top).', 0);
const TICK = whole(`Ticks from the start; ${TICKS_PER_QUARTER} to a quarter note.`, 0);
const NOTE: ArgSchema = {
description: 'The pitch: a MIDI number (60 is C4) or a name with sharps only, like "C4", "F#3", "A-1".',
};
const VELOCITY: ArgSchema = { type: 'number', minimum: 0, maximum: 1, description: 'How hard, 0 to 1.' };
const FX: ArgSchema = { type: 'string', description: 'Id of a Sound FX asset, or "" for none.' };
const DB = (description: string): ArgSchema => ({ type: 'number', description: `${description} In dB.` });
/** Notes of patterns, loops, the tracks and items of songs, sends of a routing, clips of sounds and videos. */
export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
{
name: 'add_notes',
description:
`Adds notes to a pattern; with replace, they take the place of all its notes. Time is in ticks, ${TICKS_PER_QUARTER} ` +
'to a quarter note; how many bars long the pattern is, its meter, grid, tempo and instrument are properties. ' +
'A note has to start before the end of the pattern.',
args: object(
{
asset: PATTERN,
notes: {
type: 'array',
minItems: 1,
items: object(
{ tick: TICK, length: whole('Ticks it is held.', 1), note: NOTE, velocity: VELOCITY },
['tick', 'length', 'note'],
),
description: 'The notes.',
},
replace: flag('Take away the notes it had.'),
},
['asset', 'notes'],
),
build: (args, { project }) => {
const pattern = requirePattern(project, text(args, 'asset'));
const added = (args.notes as Args[]).map((n): PatternNote => ({
id: newId(),
tick: n.tick as number,
length: n.length as number,
note: pitchOf(n.note),
velocity: (n.velocity as number | undefined) ?? DEFAULT_VELOCITY,
}));
for (const note of added) requireInside(pattern, note);
const notes = [...(args.replace === true ? [] : pattern.notes), ...added];
const command = new SetPatternNotesCommand(pattern.id, notes, 'Add notes');
return added.length === 1 ? { command, made: { id: added[0]!.id } } : { command };
},
shows: theTab,
},
{
name: 'change_notes',
description:
'Changes notes of a pattern by their ids: where they start, how long, the pitch, the velocity.',
args: object(
{
asset: PATTERN,
notes: {
type: 'array',
minItems: 1,
items: object(
{
id: id('Id of the note.'),
tick: TICK,
length: whole('Ticks it is held.', 1),
note: NOTE,
velocity: VELOCITY,
},
['id'],
),
description: 'Each with only what changes.',
},
},
['asset', 'notes'],
),
build: (args, { project }) => {
const pattern = requirePattern(project, text(args, 'asset'));
const changes = new Map((args.notes as Args[]).map((n) => [n.id as string, n]));
for (const noteId of changes.keys())
if (!pattern.notes.some((n) => n.id === noteId))
throw new CommandError(`${pattern.name} has no note ${noteId}`);
const notes = pattern.notes.map((note) => {
const change = changes.get(note.id);
if (!change) return note;
const changed: PatternNote = {
...note,
...(typeof change.tick === 'number' ? { tick: change.tick } : {}),
...(typeof change.length === 'number' ? { length: change.length } : {}),
...(change.note === undefined ? {} : { note: pitchOf(change.note) }),
...(typeof change.velocity === 'number' ? { velocity: change.velocity } : {}),
};
requireInside(pattern, changed);
return changed;
});
return { command: new SetPatternNotesCommand(pattern.id, notes, 'Change notes') };
},
shows: theTab,
},
{
name: 'remove_notes',
description: 'Removes notes of a pattern by their ids.',
args: object({ asset: PATTERN, notes: ids('Ids of the notes.') }, ['asset', 'notes']),
build: (args, { project }) => {
const pattern = requirePattern(project, text(args, 'asset'));
const going = new Set(listOf(args, 'notes'));
for (const noteId of going)
if (!pattern.notes.some((n) => n.id === noteId))
throw new CommandError(`${pattern.name} has no note ${noteId}`);
const notes = pattern.notes.filter((n) => !going.has(n.id));
return { command: new SetPatternNotesCommand(pattern.id, notes, 'Delete notes') };
},
shows: theTab,
},
{
name: 'set_loop',
description:
'Sets the loop the playhead of a pattern or a song runs round, in ticks; leave start and end out to loop the whole.',
args: object({ asset: id('Id of the pattern or song asset.'), start: TICK, end: whole('Ticks.', 1) }, [
'asset',
]),
build: (args, { project }) => {
const asset = requireAsset(project, text(args, 'asset'));
if (asset.type !== 'pattern' && asset.type !== 'song')
throw new CommandError(`${asset.name} is no pattern or song`);
const given = [args.start, args.end].filter((value) => value !== undefined).length;
if (given === 1) throw new CommandError('Give start and end, or neither for the whole');
const loop = given === 0 ? null : { start: numberOf(args, 'start'), end: numberOf(args, 'end') };
if (loop && loop.end <= loop.start) throw new CommandError('The loop has to end after it starts');
return { command: new SetLoopCommand(asset.type, asset.id, loop) };
},
shows: theTab,
},
{
name: 'add_song_track',
description:
'Adds a track to a song, at the bottom or at an index. Its routing gets a node for it, going to the output.',
args: object({ asset: SONG, name: id('Its name.'), at: INDEX }, ['asset']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = newTrack(song.tracks.length + 1);
if (typeof args.name === 'string') track.name = args.name;
const tracks = [...song.tracks];
tracks.splice(Math.min((args.at as number | undefined) ?? tracks.length, tracks.length), 0, track);
return { command: new SetSongCommand(song.id, { tracks }, 'Add track'), made: { id: track.id } };
},
shows: theTab,
},
{
name: 'change_song_track',
description: 'Changes a track of a song. Give only what changes.',
args: object(
{
asset: SONG,
track: TRACK,
name: id('Its name.'),
volume: DB(`From ${TRACK_MIN_DB} to ${TRACK_MAX_DB}.`),
pan: number('-1 all left, 1 all right.'),
mute: flag('Muted.'),
solo: flag('Soloed.'),
fx: {
...FX,
description: 'Id of the Sound FX everything on the track goes through, or "" for none.',
},
},
['asset', 'track'],
),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = requireTrack(song, text(args, 'track'));
const changed: SongTrack = { ...track };
if (typeof args.name === 'string') changed.name = args.name;
if (typeof args.volume === 'number')
changed.volume = within(args.volume, TRACK_MIN_DB, TRACK_MAX_DB, 'volume');
if (typeof args.pan === 'number') changed.pan = within(args.pan, -1, 1, 'pan');
if (typeof args.mute === 'boolean') changed.mute = args.mute;
if (typeof args.solo === 'boolean') changed.solo = args.solo;
if (typeof args.fx === 'string') changed.fx = soundFxOf(project, args.fx);
const tracks = song.tracks.map((t) => (t.id === track.id ? changed : t));
return { command: new SetSongCommand(song.id, { tracks }, 'Change track') };
},
shows: theTab,
},
{
name: 'move_song_track',
description: 'Moves a track of a song to another place in its list.',
args: object({ asset: SONG, track: TRACK, to: INDEX }, ['asset', 'track', 'to']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = requireTrack(song, text(args, 'track'));
const tracks = song.tracks.filter((t) => t.id !== track.id);
tracks.splice(Math.min(numberOf(args, 'to'), tracks.length), 0, track);
return { command: new SetSongCommand(song.id, { tracks }, 'Move track') };
},
shows: theTab,
},
{
name: 'remove_song_track',
description: 'Removes a track of a song with its items; the last track stays.',
args: object({ asset: SONG, track: TRACK }, ['asset', 'track']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = requireTrack(song, text(args, 'track'));
if (song.tracks.length <= 1) throw new CommandError('A song keeps at least one track');
const parts = {
tracks: song.tracks.filter((t) => t.id !== track.id),
items: song.items.filter((i) => i.track !== track.id),
};
return { command: new SetSongCommand(song.id, parts, 'Delete track') };
},
shows: theTab,
},
{
name: 'add_song_item',
description:
`Lays a pattern, a sound or a clip of a sound on a track of a song. Time is in ticks of the song, ${TICKS_PER_QUARTER} ` +
'to a quarter note at its tempo. Without a length it is as long as its pattern, or its sound at the tempo of the ' +
'song; a pattern item repeats its pattern for as long as it lasts.',
args: object(
{
asset: SONG,
track: TRACK,
play: id('Id of the pattern, sound or sound clip.'),
tick: TICK,
...itemFields(),
},
['asset', 'track', 'play', 'tick'],
),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
requireTrack(song, text(args, 'track'));
const play = text(args, 'play');
const kind = findAssetOf(project, play, 'pattern')
? 'pattern'
: findSound(project, play)
? 'sound'
: null;
if (!kind) throw new CommandError(`${play} is no pattern, sound or sound clip`);
const length = (args.length as number | undefined) ?? itemLength(project, song, kind, play);
const item = changedItem(
project,
newItem({ track: text(args, 'track'), kind, asset: play, tick: numberOf(args, 'tick'), length }),
args,
);
return {
command: new SetSongCommand(song.id, { items: [...song.items, item] }, 'Add item'),
made: { id: item.id },
};
},
shows: theTab,
},
{
name: 'change_song_item',
description:
'Changes an item of a song: its track, where it starts, how long, and how it plays. Give only what changes.',
args: object({ asset: SONG, item: ITEM, track: TRACK, tick: TICK, ...itemFields() }, ['asset', 'item']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const item = song.items.find((i) => i.id === text(args, 'item'));
if (!item) throw new CommandError(`${song.name} has no item ${text(args, 'item')}`);
if (typeof args.track === 'string') requireTrack(song, args.track);
const changed = changedItem(project, item, args);
const items = song.items.map((i) => (i.id === item.id ? changed : i));
return { command: new SetSongCommand(song.id, { items }, 'Change item') };
},
shows: theTab,
},
{
name: 'remove_song_items',
description: 'Removes items of a song.',
args: object({ asset: SONG, items: ids('Ids of the items.') }, ['asset', 'items']),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const going = new Set(listOf(args, 'items'));
for (const itemId of going)
if (!song.items.some((i) => i.id === itemId))
throw new CommandError(`${song.name} has no item ${itemId}`);
const items = song.items.filter((i) => !going.has(i.id));
return {
command: new SetSongCommand(song.id, { items }, going.size === 1 ? 'Delete item' : 'Delete items'),
};
},
shows: theTab,
},
{
name: 'set_send',
description:
`How loud a link of a song's routing or of the master routing (asset "${ROUTING_ID}") sends, and where it taps: ` +
'post (after volume and pan), postFx (after the sound FX, before volume and pan) or preFx (before the sound FX). ' +
'In the master routing only a bus or a song taps anywhere but post. Links are drawn with connect.',
args: object(
{
asset: id(`Id of the song, or "${ROUTING_ID}".`),
edge: id('Id of the link, as get_graph lists it.'),
volume: DB(`From ${TRACK_MIN_DB} to ${TRACK_MAX_DB}.`),
tap: oneOf(
SEND_TAPS.map((t) => t.value),
'Where it taps.',
),
},
['asset', 'edge'],
),
build: (args, { project }) => {
const holder = findGraphAsset(project, text(args, 'asset'));
if (holder?.type !== 'song' && holder?.type !== 'routing')
throw new CommandError(`${text(args, 'asset')} is no song and no master routing`);
const edge = requireEdge(project, holder.id, text(args, 'edge'));
const volume =
typeof args.volume === 'number'
? within(args.volume, TRACK_MIN_DB, TRACK_MAX_DB, 'volume')
: (edge.send?.volume ?? 0);
const tap = (args.tap as SendTap | undefined) ?? edge.send?.tap ?? 'post';
return { command: new SetSendCommand(holder.id, edge.id, { volume, tap }) };
},
shows: (args) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: [] }),
},
{
name: 'add_clip',
description:
'Adds a clip to a sound or a video: a stretch of its file with a name, which a song, a composition, a Play ' +
'sound node or a sprite can play instead of the whole. props are its rows by key (get_asset lists them as ' +
'clipProperties): start and end in seconds (end 0 is the end of the file), and on a sound reverse and its fades.',
args: object(
{
asset: id('Id of the sound or video asset.'),
name: id('Its name; else Clip 1, Clip 2, ...'),
props: { type: 'object', description: 'Its rows by key.' },
},
['asset'],
),
build: (args) => {
const add = new AddClipCommand(text(args, 'asset'), (args.props ?? {}) as PropertyBag);
const steps: Command[] = [add];
if (typeof args.name === 'string')
steps.push(new RenameClipCommand(text(args, 'asset'), add.clipId, args.name));
return { command: steps.length === 1 ? add : oneStep('Add clip', steps), made: { id: add.clipId } };
},
shows: theTab,
},
{
name: 'change_clip',
description:
'Changes a clip of a sound or a video: its name, and its rows by key. Give only what changes.',
args: object(
{
asset: id('Id of the sound or video asset.'),
clip: id('Id of the clip.'),
name: id('Its name.'),
props: { type: 'object', description: 'Rows by key.' },
},
['asset', 'clip'],
),
build: (args) => {
const steps: Command[] = [];
if (typeof args.name === 'string')
steps.push(new RenameClipCommand(text(args, 'asset'), text(args, 'clip'), args.name));
const props = (args.props ?? {}) as PropertyBag;
if (Object.keys(props).length > 0)
steps.push(new SetClipPropsCommand(text(args, 'asset'), text(args, 'clip'), props));
if (steps.length === 0) throw new CommandError('Name what to change: name or props');
return { command: steps.length === 1 ? steps[0]! : oneStep('Change clip', steps) };
},
shows: theTab,
},
{
name: 'remove_clip',
description:
'Removes a clip of a sound or a video. What plays it is left naming it and reads as missing until an undo.',
args: object({ asset: id('Id of the sound or video asset.'), clip: id('Id of the clip.') }, [
'asset',
'clip',
]),
build: (args, { project }) => {
const asset = requireAsset(project, text(args, 'asset'));
if (!hasClips(asset) || !clipsOf(asset).some((c) => c.id === text(args, 'clip')))
throw new CommandError(`${asset.name} has no clip ${text(args, 'clip')}`);
return { command: new RemoveClipCommand(asset.id, text(args, 'clip')) };
},
shows: theTab,
},
];
/** The fields an item of a song has besides its place. */
function itemFields(): Record<string, ArgSchema> {
return {
length: whole('Ticks it lasts.', 1),
offset: number('Where in what it plays it starts: ticks into a pattern, seconds into a sound.'),
volume: DB(`From ${ITEM_MIN_DB} to 0 for a pattern, to ${itemMaxDb('sound')} for a sound.`),
transpose: whole(`Semitones a pattern's notes are moved by, -${MAX_TRANSPOSE} to ${MAX_TRANSPOSE}.`),
instrument: {
type: 'string',
description: "Id of an instrument a pattern plays with instead of its own; '' for its own.",
},
fx: {
type: 'string',
description: `Id of a Sound FX it goes through; "" for the one its asset names, "${NO_FX}" for none.`,
},
};
}
/** The item with what `args` change, refused where a value is out of range or names the wrong kind of asset. */
function changedItem(project: Project, item: SongItem, args: Args): SongItem {
const out: SongItem = { ...item };
if (typeof args.track === 'string') out.track = args.track;
if (typeof args.tick === 'number') out.tick = args.tick;
if (typeof args.length === 'number') out.length = args.length;
if (typeof args.offset === 'number') {
if (args.offset < 0) throw new CommandError('The offset has to be 0 or more');
out.offset = out.kind === 'pattern' ? Math.round(args.offset) : args.offset;
}
if (typeof args.volume === 'number')
out.volume = within(args.volume, ITEM_MIN_DB, itemMaxDb(out.kind), 'volume');
if (typeof args.transpose === 'number') {
if (out.kind !== 'pattern') throw new CommandError('Only a pattern item is transposed');
out.transpose = within(args.transpose, -MAX_TRANSPOSE, MAX_TRANSPOSE, 'transpose');
}
if (typeof args.instrument === 'string') {
if (out.kind !== 'pattern') throw new CommandError('Only a pattern item plays with an instrument');
if (args.instrument !== '' && !findAssetOf(project, args.instrument, 'instrument'))
throw new CommandError(`${args.instrument} is no instrument asset`);
out.instrument = args.instrument;
}
if (typeof args.fx === 'string')
out.fx = args.fx === NO_FX || args.fx === '' ? args.fx : soundFxOf(project, args.fx);
return out;
}
/** How long an item laid in without a length is: its pattern, or its sound at the song's tempo. */
function itemLength(project: Project, song: SongAsset, kind: SongItem['kind'], play: string): number {
const pattern = kind === 'pattern' ? findAssetOf(project, play, 'pattern') : undefined;
if (pattern) return Math.max(1, patternLength(pattern));
const seconds = audioSeconds(project, { kind: 'sound', asset: play, volume: 0, bus: '' });
if (seconds <= 0)
throw new CommandError(
`The length of ${findSound(project, play)?.asset.name ?? play} is not known yet – give a length ` +
`(a bar of this song is ${ticksPerBar(songMeter(song.props))} ticks)`,
);
return Math.max(1, Math.round(ticksOfSeconds(seconds, songTempo(song.props))));
}
function requireTrack(song: SongAsset, trackId: string): SongTrack {
const track = song.tracks.find((t) => t.id === trackId);
if (!track) throw new CommandError(`${song.name} has no track ${trackId}`);
return track;
}
/** A pitch the agent wrote as a MIDI number or a name like "C#4". */
export function pitchOf(value: unknown): number {
const number =
typeof value === 'number'
? value
: typeof value === 'string'
? NOTE_BY_NAME.get(value.trim().toUpperCase())
: undefined;
if (number === undefined || !Number.isInteger(number) || number < LOWEST_NOTE || number > HIGHEST_NOTE)
throw new CommandError(
`${JSON.stringify(value)} is no note – give ${LOWEST_NOTE} to ${HIGHEST_NOTE} or a name like "C4" or "F#3"`,
);
return number;
}
const NOTE_BY_NAME = new Map(
Array.from({ length: HIGHEST_NOTE - LOWEST_NOTE + 1 }, (_, i) => [
noteName(LOWEST_NOTE + i),
LOWEST_NOTE + i,
]),
);
function requireInside(pattern: PatternAsset, note: PatternNote): void {
const end = patternLength(pattern);
if (note.tick >= end)
throw new CommandError(
`A note at tick ${note.tick} lies past the end of ${pattern.name}, which is ${end} ticks long – ` +
'give it more bars first',
);
}
function within(value: number, min: number, max: number, what: string): number {
if (value < min || value > max) throw new CommandError(`The ${what} has to be from ${min} to ${max}`);
return value;
}
function soundFxOf(project: Project, fx: string): string {
if (fx !== '' && findAsset(project, fx)?.type !== 'soundfx')
throw new CommandError(`${fx} is no Sound FX asset`);
return fx;
}
+13
View File
@@ -1,6 +1,8 @@
import { ANIMATION_COMMANDS } from './catalog/animation'; import { ANIMATION_COMMANDS } from './catalog/animation';
import { ASSET_COMMANDS } from './catalog/assets'; import { ASSET_COMMANDS } from './catalog/assets';
import { COMPOSITION_COMMANDS } from './catalog/composition';
import { GRAPH_COMMANDS } from './catalog/graphs'; import { GRAPH_COMMANDS } from './catalog/graphs';
import { MUSIC_COMMANDS } from './catalog/music';
import { OBJECT_COMMANDS } from './catalog/objects'; import { OBJECT_COMMANDS } from './catalog/objects';
import { PIXEL_ART_COMMANDS } from './catalog/pixelart'; import { PIXEL_ART_COMMANDS } from './catalog/pixelart';
import { PROJECT_COMMANDS } from './catalog/project'; import { PROJECT_COMMANDS } from './catalog/project';
@@ -63,6 +65,17 @@ export const AREAS: readonly CommandArea[] = [
about: 'Shapes of Vector art assets: their outlines, fills, gradients and strokes, groups and layers.', about: 'Shapes of Vector art assets: their outlines, fills, gradients and strokes, groups and layers.',
commands: VECTOR_ART_COMMANDS, commands: VECTOR_ART_COMMANDS,
}, },
{
area: 'music',
about:
'Notes of patterns; tracks, items and loops of songs; sends of a routing; clips of sounds and videos.',
commands: MUSIC_COMMANDS,
},
{
area: 'composition',
about: 'Tracks, items and animation clips of Composition assets.',
commands: COMPOSITION_COMMANDS,
},
{ area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS }, { area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS },
]; ];
+21
View File
@@ -6,6 +6,13 @@ import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import { isTemplateContent, resolveObjects } from '../../core/model/instance'; import { isTemplateContent, resolveObjects } from '../../core/model/instance';
import { findAsset, findGraphAsset, type Project } from '../../core/model/Project'; import { findAsset, findGraphAsset, type Project } from '../../core/model/Project';
import { audioSeconds } from '../../core/model/composition';
import { noteName } from '../../core/instrument/notes';
import { patternLength } from '../../core/music/pattern';
import { songLength } from '../../core/music/song';
import { TICKS_PER_QUARTER } from '../../core/music/time';
import { CLIP_ROWS } from '../../core/properties/sounds';
import { VIDEO_CLIP_ROWS } from '../../core/properties/videos';
import { celOf, paletteColors } from '../../core/model/pixelart'; import { celOf, paletteColors } from '../../core/model/pixelart';
import type { CelPixels } from '../../core/model/pixelCel'; import type { CelPixels } from '../../core/model/pixelCel';
import { objectName } from '../../core/model/SceneObject'; import { objectName } from '../../core/model/SceneObject';
@@ -81,6 +88,19 @@ export function getAsset(project: Project, assetId: string, readCel?: CelLookup)
plain.colors = paletteColors(project, asset).map(toHex); plain.colors = paletteColors(project, asset).map(toHex);
plain.frames = pixelFrames(asset, readCel); plain.frames = pixelFrames(asset, readCel);
} }
if (asset.type === 'pattern') {
plain.notes = asset.notes.map((note) => ({ ...note, name: noteName(note.note) }));
plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: patternLength(asset) };
}
if (asset.type === 'song') plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: songLength(asset) };
if (asset.type === 'sound' || asset.type === 'video') {
plain.clipProperties = (asset.type === 'sound' ? CLIP_ROWS : VIDEO_CLIP_ROWS).map(propertyRow);
}
if (asset.type === 'sound') {
// Known once the editor has loaded it; a song or composition item takes its length from it.
const seconds = audioSeconds(project, { kind: 'sound', asset: asset.id, volume: 0, bus: '' });
plain.seconds = seconds > 0 ? seconds : 'not loaded';
}
plain.properties = schemaForAsset(project, asset).map(propertyRow); plain.properties = schemaForAsset(project, asset).map(propertyRow);
return plain; return plain;
} }
@@ -169,6 +189,7 @@ export function getGraph(project: Project, assetId: string): object | null {
from: edge.from, from: edge.from,
to: edge.to, to: edge.to,
...(edge.label ? { label: edge.label } : {}), ...(edge.label ? { label: edge.label } : {}),
...(edge.send ? { send: edge.send } : {}),
})), })),
groups: (graph.groups ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), title: text })), groups: (graph.groups ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), title: text })),
notes: (graph.notes ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), text })), notes: (graph.notes ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), text })),
+2 -2
View File
@@ -44,7 +44,7 @@
{ {
"name": "get_asset", "name": "get_asset",
"title": "Read an asset", "title": "Read an asset",
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"). A graph is only counted here; read it with get_graph.", "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"), the notes of a pattern with their names and how many ticks it is long, the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts. A graph is only counted here; read it with get_graph.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset." } }, "properties": { "asset": { "type": "string", "description": "Id of the asset." } },
@@ -56,7 +56,7 @@
{ {
"name": "get_graph", "name": "get_graph",
"title": "Read a graph", "title": "Read a graph",
"description": "The graph of a behavior, state machine, shader, particles, sound FX or instrument: each node with its settings and its input and output ports as they are now, the edges between ports, and the groups and comments (notes). A node that declares its own ports lists them under ports.", "description": "The graph of a behavior, state machine, shader, particles, sound FX or instrument: each node with its settings and its input and output ports as they are now, the edges between ports (a link of a routing with its send), and the groups and comments (notes). A node that declares its own ports lists them under ports.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } }, "properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } },
+425
View File
@@ -0,0 +1,425 @@
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');
});
});
+53
View File
@@ -463,6 +463,59 @@ export async function mcp(t) {
}), }),
); );
// 71g8. Music and compositions (#429 stage 5d): notes the agent writes, and the pattern and the
// sprite laid on a composition, which comes up in its tab.
const composed = await callTool('run_commands', {
label: 'compose',
steps: [
{ command: 'create_asset', args: { type: 'pattern', name: `${TOKEN_NAME} riff` }, as: 'riff' },
{
command: 'add_notes',
args: {
asset: '$riff',
notes: [
{ tick: 0, length: 480, note: 'C4' },
{ tick: 480, length: 480, note: 'E4' },
],
},
},
{ command: 'create_asset', args: { type: 'composition', name: `${TOKEN_NAME} cut` }, as: 'cut' },
{ command: 'add_composition_track', args: { asset: '$cut', name: 'Music' }, as: 'music' },
{
command: 'add_composition_item',
args: { asset: '$cut', track: '$music', place: '$riff', start: 0 },
},
{
command: 'add_composition_item',
args: { asset: '$cut', track: '$music', place: heroId, start: 30 },
},
],
});
await settle();
const composedMade = composed?.structuredContent?.made ?? [];
const riffId = composedMade.find((entry) => entry.step === 1)?.id;
const cutId = composedMade.find((entry) => entry.step === 3)?.id;
const cutView = await viewNow();
const riffRead = riffId ? (await callTool('get_asset', { asset: riffId }))?.structuredContent : null;
const cutRead = cutId ? (await callTool('get_asset', { asset: cutId }))?.structuredContent : null;
await callTool('undo');
await callTool('open', { asset: walkId });
await settle();
check(
'notes the agent writes read back by name, and a pattern and a sprite it lays on a composition open in its tab',
composed?.isError === false &&
cutView?.document?.asset?.id === cutId &&
JSON.stringify(riffRead?.notes?.map((note) => note.name)) === '["C4","E4"]' &&
JSON.stringify(cutRead?.items?.map((item) => [item.start, item.length])) === '[[0,480],[30,180]]' &&
cutRead?.objects?.length === 1,
JSON.stringify({
text: composed?.content?.[0]?.text?.slice(0, 300),
document: cutView?.document,
notes: riffRead?.notes,
items: cutRead?.items,
}),
);
// 71h. Played by the agent, the object walks left while the user holds the arrow. // 71h. Played by the agent, the object walks left while the user holds the arrow.
const started = await callTool('play', { action: 'play' }); const started = await callTool('play', { action: 'play' });
await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' }); await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });