Let the agent set automation lanes of a song track (#337)
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:
1 parent
b50c7dbc35
commit
2099511695
5 files changed
+292
-5
No files matched your search
@@ -1,12 +1,19 @@
|
||||
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 type {
|
||||
BehaviorAsset,
|
||||
CompositionAsset,
|
||||
PatternAsset,
|
||||
SongAsset,
|
||||
SoundAsset,
|
||||
SoundFxAsset,
|
||||
} 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 { graphEditor, makeSpriteScene, type SceneFixture } from '../helpers';
|
||||
import { hostFor, text } from './agentTestHost';
|
||||
|
||||
/** 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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { Theme } = await made(fixture, { Theme: 'song' });
|
||||
|
||||
Reference in new issue
Block a user