Let the agent set automation lanes of a song track (#337)
feuerwerk / gate (push) Successful in 6m3s
feuerwerk / suite (push) Successful in 20m14s

Stage 4 of #337. Lanes could only be drawn in the song tab. Now the
agent can set them in one step and sees which parameters a lane of a
track can move.

- New command set_song_lane. The target is named as it is stored:
  volume, pan or param:<name>. All points come at once, each with
  tick, value and ease, from the same eases as animation keys,
  curve assets included.
- Points on one tick keep the order they come in, which makes a jump.
  points [] removes a volume or pan lane, remove a parameter lane.
- It refuses with a clear message: a value outside the range, an
  unknown ease, a missing curve asset, a range for volume or pan, and
  a new lane for a parameter nothing on the track has. The message
  then names the parameters there are.
- Without min and max a new parameter lane gets the range the dialog
  in the song tab offers.
- get_asset names under params of each song track the parameters a
  lane can move, with their default. The lanes were already in it.

Live in the user's project: get_asset named both parameters of their
two lanes (frequency, env) with defaults 20000 and 0.5. Playing to
3 s and 4.2 s, both tracks were heard, the audio clock running.

Tests:
- agent-music 13/13 (2 new), agent and MCP tests 129/129
- 6 counter-probes, all compile, all red
- gate on atx: CHECK EXIT 0, 330 files, 5082 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-19 10:54:51 +02:00
1 parent b50c7dbc35
commit 2099511695
5 files changed
+292 -5

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ const keyOf = (args: Args, name: string): AnimationKey => {
}; };
/** Every easing a key can name, but the curve ones: those take the id of a Curve asset. */ /** Every easing a key can name, but the curve ones: those take the id of a Curve asset. */
const EASINGS = EASING_SHAPES.flatMap(({ value }) => export const EASINGS = EASING_SHAPES.flatMap(({ value }) =>
value === 'linear' || value === 'step' value === 'linear' || value === 'step'
? [value] ? [value]
: value === 'curve' : value === 'curve'
+147
View File
@@ -20,6 +20,7 @@ import {
TONE_PRESETS, TONE_PRESETS,
} from '../../../core/instrument/chipPresets'; } from '../../../core/instrument/chipPresets';
import { HIGHEST_NOTE, LOWEST_NOTE, noteName } from '../../../core/instrument/notes'; import { HIGHEST_NOTE, LOWEST_NOTE, noteName } from '../../../core/instrument/notes';
import { easingOf, isKeyInterpolation } from '../../../core/model/animation';
import type { PatternAsset, SongAsset } from '../../../core/model/Asset'; import type { PatternAsset, SongAsset } from '../../../core/model/Asset';
import { audioSeconds } from '../../../core/model/composition'; import { audioSeconds } from '../../../core/model/composition';
import type { PropertyBag } from '../../../core/model/Properties'; import type { PropertyBag } from '../../../core/model/Properties';
@@ -31,6 +32,15 @@ import {
ROUTING_ID, ROUTING_ID,
} from '../../../core/model/Project'; } from '../../../core/model/Project';
import { clipsOf, findSound, hasClips } from '../../../core/model/sound'; import { clipsOf, findSound, hasClips } from '../../../core/model/sound';
import {
type AutomationLane,
type AutomationPoint,
heldRange,
isAutomationTarget,
type LaneKind,
laneRange,
paramOf,
} from '../../../core/music/automation';
import { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern'; import { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern';
import { SEND_TAPS } from '../../../core/music/routing'; import { SEND_TAPS } from '../../../core/music/routing';
import { import {
@@ -47,8 +57,11 @@ import {
TRACK_MIN_DB, TRACK_MIN_DB,
} from '../../../core/music/song'; } from '../../../core/music/song';
import { TICKS_PER_QUARTER, ticksOfSeconds, ticksPerBar } from '../../../core/music/time'; import { TICKS_PER_QUARTER, ticksOfSeconds, ticksPerBar } from '../../../core/music/time';
import { defaultParamRange, trackParams } from '../../../core/music/trackParams';
import { NO_FX } from '../../../core/properties/assetFx'; import { NO_FX } from '../../../core/properties/assetFx';
import { withLanePoints, withoutLane, withParamLane } from '../../song/laneEdit';
import type { ArgSchema } from '../argCheck'; import type { ArgSchema } from '../argCheck';
import { EASINGS } from './animation';
import { import {
type Args, type Args,
type CatalogCommand, type CatalogCommand,
@@ -280,6 +293,81 @@ export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
}, },
shows: theTab, shows: theTab,
}, },
{
name: 'set_song_lane',
description:
'Sets an automation lane of a song track: its volume (in dB), its pan (-1 left to 1 right) or a parameter ' +
'changing over the song along points; the points given replace those the lane had. From each point the ' +
'value runs to the next along its ease; before the first point it holds the first value, after the last ' +
'the last one; two points on one tick jump there. While the lane of the volume or the pan has a point, it ' +
"rules that value in place of the track's own; with no point that lane goes and the track's value rules " +
'again. A parameter lane moves every parameter of that name on the track: of its sound FX, of the sound FX ' +
'of its items, of the instruments its patterns play and of their sound FX (get_asset lists them under ' +
"params of the track). It keeps a range its points lie in, round the parameter's default where none is " +
'given, and stays without a point until remove. A note or clip starts at the value there, so what a ' +
'voice reads only when it starts changes from the next one.',
args: object(
{
asset: SONG,
track: TRACK,
target: id(
'"volume", "pan", or "param:<name>" for a parameter, as get_asset lists lanes under automation.',
),
points: {
type: 'array',
items: object(
{
tick: TICK,
value: number(
'In the units of the lane: dB, -1 to 1 for the pan, within the range of a parameter.',
),
ease: id(
`How it runs to the next point: ${EASINGS.join(', ')}, or "curve:<id of a Curve asset>"; ` +
'linear where left out.',
),
},
['tick', 'value'],
),
description: 'All points of the lane, in any order; those on one tick in the order given.',
},
min: number('A parameter lane: the lowest value it holds.'),
max: number('A parameter lane: the highest value it holds.'),
remove: flag("Take the lane away, a parameter's range with it."),
},
['asset', 'track', 'target'],
),
build: (args, { project }) => {
const song = requireSong(project, text(args, 'asset'));
const track = requireTrack(song, text(args, 'track'));
const target = args.target;
if (!isAutomationTarget(target))
throw new CommandError(
`${JSON.stringify(target)} is no lane – give "volume", "pan" or "param:<name>"`,
);
const had = track.automation?.find((lane) => lane.target === target);
const given = [args.points, args.min, args.max].some((value) => value !== undefined);
let changed: SongTrack;
let label: string;
if (args.remove === true) {
if (given) throw new CommandError('remove takes no points and no range');
if (!had) throw new CommandError(`${track.name} has no ${target} lane`);
changed = withoutLane(track, target);
label = 'Remove automation lane';
} else {
const name = paramOf(target);
const range = name === null ? noRange(args) : paramRange(project, song, track, name, had, args);
const kind: LaneKind = { target, ...range };
const points = args.points === undefined ? null : lanePointsOf(project, kind, args.points as Args[]);
if (name === null && !points) throw new CommandError('Give the points, [] to take the lane away');
changed = range ? withParamLane(track, name!, range) : track;
if (points) changed = withLanePoints(changed, target, points);
label = had ? 'Set automation lane' : 'Add automation lane';
}
const tracks = song.tracks.map((t) => (t.id === track.id ? changed : t));
return { command: new SetSongCommand(song.id, { tracks }, label) };
},
shows: theTab,
},
{ {
name: 'move_song_track', name: 'move_song_track',
description: 'Moves a track of a song to another place in its list.', description: 'Moves a track of a song to another place in its list.',
@@ -537,6 +625,65 @@ function itemLength(project: Project, song: SongAsset, kind: SongItem['kind'], p
return Math.max(1, Math.round(ticksOfSeconds(seconds, songTempo(song.props)))); return Math.max(1, Math.round(ticksOfSeconds(seconds, songTempo(song.props))));
} }
/** Refuses a range given to the lane of the volume or the pan: theirs is fixed. */
function noRange(args: Args): null {
if (args.min !== undefined || args.max !== undefined)
throw new CommandError('Only a parameter lane has a range – the volume and the pan have theirs');
return null;
}
/**
* The range of a parameter's lane: the one given, else the one it has, else one round the default of
* the parameter. A new lane is refused for a parameter nothing on the track has.
*/
function paramRange(
project: Project,
song: SongAsset,
track: SongTrack,
name: string,
had: AutomationLane | undefined,
args: Args,
): { min: number; max: number } {
const offered = trackParams(project, song, track);
const param = offered.find((p) => p.name === name);
if (!had && !param)
throw new CommandError(
offered.length === 0
? `Nothing on ${track.name} has a parameter to move`
: `Nothing on ${track.name} has a parameter ${name} – it has ${offered.map((p) => p.name).join(', ')}`,
);
const given = [args.min, args.max].filter((value) => value !== undefined).length;
if (given === 1) throw new CommandError('Give min and max, or neither');
if (given === 2) {
const range = heldRange(args.min, args.max);
if (!range) throw new CommandError('max has to be above min');
return range;
}
return had ? laneRange(had) : defaultParamRange(param!.default);
}
/**
* The points the agent wrote, refused where a value or an easing is wrong. The song puts them in the
* order of their ticks, those on one tick as they came.
*/
function lanePointsOf(project: Project, kind: LaneKind, raw: readonly Args[]): AutomationPoint[] {
const { min, max } = laneRange(kind);
return raw.map((point): AutomationPoint => {
const tick = point.tick as number;
const value = within(point.value as number, min, max, `value at tick ${tick}`);
const ease = point.ease;
if (ease === undefined || ease === 'linear') return { tick, value };
if (!isKeyInterpolation(ease))
throw new CommandError(
`${JSON.stringify(ease)} is no easing – give ${EASINGS.join(', ')} or "curve:<id>"`,
);
const { curve } = easingOf(ease);
if (curve !== undefined && !findAssetOf(project, curve, 'curve'))
throw new CommandError(`${curve} is no Curve asset`);
return { tick, value, ease };
});
}
function requireTrack(song: SongAsset, trackId: string): SongTrack { function requireTrack(song: SongAsset, trackId: string): SongTrack {
const track = song.tracks.find((t) => t.id === trackId); const track = song.tracks.find((t) => t.id === trackId);
if (!track) throw new CommandError(`${song.name} has no track ${trackId}`); if (!track) throw new CommandError(`${song.name} has no track ${trackId}`);
+10 -1
View File
@@ -17,6 +17,7 @@ import { instrumentSourcesOf, soundingNotes } from '../../core/instrument/compil
import { noteName } from '../../core/instrument/notes'; import { noteName } from '../../core/instrument/notes';
import { patternInstrument, patternLength, patternSequence } from '../../core/music/pattern'; import { patternInstrument, patternLength, patternSequence } from '../../core/music/pattern';
import { itemNotes, songLength } from '../../core/music/song'; import { itemNotes, songLength } from '../../core/music/song';
import { trackParams } from '../../core/music/trackParams';
import { TICKS_PER_QUARTER } from '../../core/music/time'; import { TICKS_PER_QUARTER } from '../../core/music/time';
import { CLIP_ROWS } from '../../core/properties/sounds'; import { CLIP_ROWS } from '../../core/properties/sounds';
import { VIDEO_CLIP_ROWS } from '../../core/properties/videos'; import { VIDEO_CLIP_ROWS } from '../../core/properties/videos';
@@ -100,7 +101,15 @@ export function getAsset(project: Project, assetId: string, readCel?: CelLookup)
plain.notes = asset.notes.map((note) => ({ ...note, name: noteName(note.note) })); plain.notes = asset.notes.map((note) => ({ ...note, name: noteName(note.note) }));
plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: patternLength(asset) }; 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 === 'song') {
plain.ticks = { perQuarter: TICKS_PER_QUARTER, length: songLength(asset) };
// What a lane of each track can move (#337): set_song_lane takes them as "param:<name>".
plain.tracks = asset.tracks.map((track, i) => {
const params = trackParams(project, asset, track);
const stored = (plain.tracks as object[])[i];
return params.length > 0 ? { ...stored, params } : stored;
});
}
const silent = silentParts(project, asset); const silent = silentParts(project, asset);
if (silent.length > 0) plain.silent = silent; if (silent.length > 0) plain.silent = silent;
if (asset.type === 'sound' || asset.type === 'video') { if (asset.type === 'sound' || asset.type === 'video') {
+1 -1
View File
@@ -59,7 +59,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\"), 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, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. 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, on each track of a song its automation lanes and the parameters a lane can move (params), the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. 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." } },
+133 -2
View File
@@ -1,12 +1,19 @@
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { CreateSoundCommand } from '../../src/core/commands/assetCommands'; import { CreateSoundCommand } from '../../src/core/commands/assetCommands';
import type { CompositionAsset, PatternAsset, SongAsset, SoundAsset } from '../../src/core/model/Asset'; import type {
BehaviorAsset,
CompositionAsset,
PatternAsset,
SongAsset,
SoundAsset,
SoundFxAsset,
} from '../../src/core/model/Asset';
import { setSoundMeasure } from '../../src/core/model/composition'; import { setSoundMeasure } from '../../src/core/model/composition';
import { ROUTING_ID } from '../../src/core/model/Project'; import { ROUTING_ID } from '../../src/core/model/Project';
import { settleRouting } from '../../src/core/music/projectRouting'; import { settleRouting } from '../../src/core/music/projectRouting';
import { TO_COMPOSITION_BUS } from '../../src/core/properties/buses'; import { TO_COMPOSITION_BUS } from '../../src/core/properties/buses';
import { runTool } from '../../src/editor/agent/agentTools'; import { runTool } from '../../src/editor/agent/agentTools';
import { makeSpriteScene, type SceneFixture } from '../helpers'; import { graphEditor, makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost'; import { hostFor, text } from './agentTestHost';
/** The commands of #429 stage 5d: patterns, songs, sends, clips of sounds and compositions. */ /** The commands of #429 stage 5d: patterns, songs, sends, clips of sounds and compositions. */
@@ -217,6 +224,130 @@ describe('songs', () => {
expect(noFx.text).toContain('is no Sound FX asset'); expect(noFx.text).toContain('is no Sound FX asset');
}); });
it('sets the volume lane of a track in tick order with its easings, refuses what is out of range, and clears it with []', async () => {
const fixture = makeSpriteScene();
const { Theme, Bend } = await made(fixture, { Theme: 'song', Bend: 'curve' });
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const set = await run(
fixture,
[
{
command: 'set_song_lane',
args: {
asset: Theme,
track,
target: 'volume',
points: [
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12, ease: 'linear' },
],
},
},
],
true,
);
expect(set.result.isError).toBeUndefined();
expect(set.shown).toEqual([{ kind: 'asset', asset: Theme, open: true }]);
// In tick order, two on one tick as given (a jump); linear is stored as no easing.
expect(song().tracks[0]!.automation).toEqual([
{
target: 'volume',
points: [
{ tick: 0, value: -6, ease: 'sine-in' },
{ tick: 1920, value: -24, ease: 'step' },
{ tick: 1920, value: 0, ease: `curve:${Bend}` },
{ tick: 3840, value: -12 },
],
},
]);
const lanes = (await read(fixture, Theme)).tracks as { automation?: unknown[] }[];
expect(lanes[0]!.automation).toHaveLength(1);
const point = (value: number, ease?: string) => [{ tick: 0, value, ...(ease && { ease }) }];
const refused = async (target: string, points: object[], extra: object = {}) =>
(
await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target, points, ...extra } },
])
).text;
expect(await refused('volume', point(13))).toContain('value at tick 0 has to be from -60 to 12');
expect(await refused('pan', point(-2))).toContain('from -1 to 1');
expect(await refused('volume', point(0, 'wobbly'))).toContain('"wobbly" is no easing');
expect(await refused('volume', point(0, 'curve:nope'))).toContain('nope is no Curve asset');
expect(await refused('loudness', point(0))).toContain('is no lane');
expect(await refused('volume', point(0), { min: 0, max: 1 })).toContain('Only a parameter lane');
expect(song().tracks[0]!.automation![0]!.points).toHaveLength(4);
const nothing = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'pan' } },
]);
expect(nothing.text).toContain('Give the points');
const cleared = await run(fixture, [
{ command: 'set_song_lane', args: { asset: Theme, track, target: 'volume', points: [] } },
]);
expect(cleared.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
});
it('adds a parameter lane only for a parameter on the track, round its default, and get_asset lists what a lane can move', async () => {
const fixture = makeSpriteScene();
const { Theme, Filter } = await made(fixture, { Theme: 'song', Filter: 'soundfx' });
const fx = byId<SoundFxAsset>(fixture, Filter);
const edit = graphEditor(fixture, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain');
const output = edit.addNode('SoundFxOutput');
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
edit.connect(input, 'out', gain, 'in');
edit.connect(input, 'cut', gain, 'gain');
edit.connect(gain, 'out', output, 'in');
const song = () => byId<SongAsset>(fixture, Theme);
const track = song().tracks[0]!.id;
const lane = (args: object) => ({
command: 'set_song_lane',
args: { asset: Theme, track, target: 'param:cut', ...args },
});
const none = await run(fixture, [lane({})]);
expect(none.text).toContain('Nothing on Track 1 has a parameter to move');
expect(((await read(fixture, Theme)).tracks as object[])[0]).not.toHaveProperty('params');
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: Filter } }]);
expect(((await read(fixture, Theme)).tracks as { params?: object }[])[0]!.params).toEqual([
{ name: 'cut', default: 800 },
]);
const other = await run(fixture, [{ ...lane({}), args: { asset: Theme, track, target: 'param:tone' } }]);
expect(other.text).toContain('Nothing on Track 1 has a parameter tone – it has cut');
// Without a range it gets one round the default, and keeps it without a point.
const added = await run(fixture, [lane({})]);
expect(added.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toEqual([{ target: 'param:cut', points: [], min: 0, max: 2000 }]);
const pointed = await run(fixture, [lane({ points: [{ tick: 0, value: 1500 }] })]);
expect(pointed.result.isError).toBeUndefined();
const over = await run(fixture, [lane({ points: [{ tick: 0, value: 2500 }] })]);
expect(over.text).toContain('from 0 to 2000');
// A narrower range holds the points it had.
await run(fixture, [lane({ min: 100, max: 1000 })]);
expect(song().tracks[0]!.automation).toEqual([
{ target: 'param:cut', points: [{ tick: 0, value: 1000 }], min: 100, max: 1000 },
]);
expect((await run(fixture, [lane({ min: 5 })])).text).toContain('Give min and max');
expect((await run(fixture, [lane({ min: 5, max: 5 })])).text).toContain('max has to be above min');
// A lane there is stays settable when its parameter is gone from the track.
await run(fixture, [{ command: 'change_song_track', args: { asset: Theme, track, fx: '' } }]);
expect((await run(fixture, [lane({ min: 0, max: 10 })])).result.isError).toBeUndefined();
expect((await run(fixture, [lane({ remove: true, min: 0 })])).text).toContain('remove takes no points');
const removed = await run(fixture, [lane({ remove: true })]);
expect(removed.result.isError).toBeUndefined();
expect(song().tracks[0]!.automation).toBeUndefined();
expect((await run(fixture, [lane({ remove: true })])).text).toContain('Track 1 has no param:cut lane');
});
it('sets the send of a song link and of a master routing link, where a sound only sends post', async () => { 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 fixture = makeSpriteScene();
const { Theme } = await made(fixture, { Theme: 'song' }); const { Theme } = await made(fixture, { Theme: 'song' });