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 { ASSET_COMMANDS } from './catalog/assets';
import { COMPOSITION_COMMANDS } from './catalog/composition';
import { GRAPH_COMMANDS } from './catalog/graphs';
import { MUSIC_COMMANDS } from './catalog/music';
import { OBJECT_COMMANDS } from './catalog/objects';
import { PIXEL_ART_COMMANDS } from './catalog/pixelart';
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.',
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 },
];
+21
View File
@@ -6,6 +6,13 @@ import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from
import { graphLookup } from '../../core/model/graphLookup';
import { isTemplateContent, resolveObjects } from '../../core/model/instance';
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 type { CelPixels } from '../../core/model/pixelCel';
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.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);
return plain;
}
@@ -169,6 +189,7 @@ export function getGraph(project: Project, assetId: string): object | null {
from: edge.from,
to: edge.to,
...(edge.label ? { label: edge.label } : {}),
...(edge.send ? { send: edge.send } : {}),
})),
groups: (graph.groups ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), title: text })),
notes: (graph.notes ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), text })),
+2 -2
View File
@@ -44,7 +44,7 @@
{
"name": "get_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": {
"type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
@@ -56,7 +56,7 @@
{
"name": "get_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": {
"type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } },