Stage 1 of #338. Game sync needs named places in a song: an event when the playhead passes one, and a switch that jumps to one. This stage adds the markers themselves. Games do nothing with them yet. - A song has markers, each with id, name and tick. A song without markers stores nothing for them. Two markers may share tick and name, since nodes will name them by id. - Markers change neither the sound nor the bake fingerprint. - The song tab draws a strip under the ruler. A marker has a green flag with its name and a faint line through the tracks. Tracks and lanes sit 18 px lower. The ruler of the pattern tab is unchanged. - Double click on an empty place adds a marker on the grid, with Alt or Shift on the exact tick. Dragging moves it in one undo step. - A click on a marker sets the start of playback there. A double click renames it in the name dialog of the hierarchy groups. - The context menu offers Play from here, Rename and Delete marker, on an empty place Add marker. A tooltip shows the full name. The wheel over the strip zooms time like the ruler. - New agent commands add_song_marker, change_song_marker and remove_song_marker. get_asset lists them under markers. Tests: - song-markers in core 6 and editor 4, 1 agent test, with the touched song, agent and MCP tests 175/175 - 9 counter-probes, all compile, all red - e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe where a dragged marker is not taken over: 65g3 red - gate on atx: CHECK EXIT 0, 332 files, 5093 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
695 lines
31 KiB
TypeScript
695 lines
31 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
||
import { CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
||
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 { 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. */
|
||
|
||
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('adds, renames, moves and removes markers of a song (#338), and get_asset lists them', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { Theme } = await made(fixture, { Theme: 'song' });
|
||
const song = () => byId<SongAsset>(fixture, Theme);
|
||
const done = await run(fixture, [
|
||
{ command: 'add_song_marker', args: { asset: Theme, tick: 7680, name: 'Boss' }, as: 'boss' },
|
||
{ command: 'add_song_marker', args: { asset: Theme, tick: 0 } },
|
||
{ command: 'change_song_marker', args: { asset: Theme, marker: '$boss', tick: 3840 } },
|
||
]);
|
||
expect(done.result.isError).toBeUndefined();
|
||
expect(song().markers!.map(({ name, tick }) => [name, tick])).toEqual([
|
||
['Marker 1', 0],
|
||
['Boss', 3840],
|
||
]);
|
||
const listed = (await read(fixture, Theme)).markers as { id: string; name: string; tick: number }[];
|
||
expect(listed.map((m) => m.name)).toEqual(['Marker 1', 'Boss']);
|
||
|
||
const boss = song().markers![1]!.id;
|
||
const blank = await run(fixture, [
|
||
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: ' ' } },
|
||
]);
|
||
expect(blank.text).toContain('A marker needs a name');
|
||
const renamed = await run(fixture, [
|
||
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: 'Fight' } },
|
||
]);
|
||
expect(renamed.result.isError).toBeUndefined();
|
||
expect(song().markers![1]).toEqual({ id: boss, name: 'Fight', tick: 3840 });
|
||
|
||
const gone = await run(fixture, [
|
||
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||
]);
|
||
expect(gone.result.isError).toBeUndefined();
|
||
expect(song().markers!.map((m) => m.name)).toEqual(['Marker 1']);
|
||
const missing = await run(fixture, [
|
||
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||
]);
|
||
expect(missing.text).toContain(`Theme has no marker ${boss}`);
|
||
});
|
||
|
||
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' });
|
||
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');
|
||
});
|
||
});
|
||
|
||
describe('silent music', () => {
|
||
it('get_asset says what plays nothing for want of an instrument, until it has one', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { Riff, Theme, Cut, Lead } = await made(fixture, {
|
||
Riff: 'pattern',
|
||
Theme: 'song',
|
||
Cut: 'composition',
|
||
Lead: 'instrument',
|
||
});
|
||
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
|
||
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
|
||
const built = await run(fixture, [
|
||
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0 }, as: 'item' },
|
||
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
|
||
]);
|
||
expect(built.result.isError).toBeUndefined();
|
||
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
|
||
expect((await read(fixture, Riff)).silent).toEqual([
|
||
'It has no instrument and plays nothing: set the property instrument of the pattern to an Instrument asset.',
|
||
]);
|
||
expect((await read(fixture, Theme)).silent).toEqual([expect.stringContaining(`Item ${item} plays Riff`)]);
|
||
expect((await read(fixture, Cut)).silent).toEqual([expect.stringContaining('which is not all heard')]);
|
||
|
||
// The item's own instrument is enough for the song; the pattern's for all of them.
|
||
await run(fixture, [{ command: 'change_song_item', args: { asset: Theme, item, instrument: Lead } }]);
|
||
expect((await read(fixture, Theme)).silent).toBeUndefined();
|
||
expect((await read(fixture, Riff)).silent).toHaveLength(1);
|
||
await run(fixture, [
|
||
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
|
||
]);
|
||
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
|
||
});
|
||
|
||
it('names the notes outside what the instrument sounds, after the transpose, until its range takes them in', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { Riff, Theme, Cut, Lead } = await made(fixture, {
|
||
Riff: 'pattern',
|
||
Theme: 'song',
|
||
Cut: 'composition',
|
||
Lead: 'instrument',
|
||
});
|
||
const node = (type: string) =>
|
||
(
|
||
fixture.project.assets.find((a) => a.id === Lead) as {
|
||
graph: { nodes: { id: string; type: string }[] };
|
||
}
|
||
).graph.nodes.find((n) => n.type === type)!.id;
|
||
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
|
||
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
|
||
const built = await run(fixture, [
|
||
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
|
||
{
|
||
command: 'add_notes',
|
||
args: {
|
||
asset: Riff,
|
||
notes: [
|
||
{ tick: 0, length: 480, note: 'C4' },
|
||
{ tick: 480, length: 480, note: 'C5' },
|
||
],
|
||
},
|
||
},
|
||
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0, transpose: -12 } },
|
||
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
|
||
]);
|
||
expect(built.result.isError).toBeUndefined();
|
||
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
|
||
|
||
// A new instrument's Sample waits for its sound: no note is heard, and get_graph says why.
|
||
expect((await read(fixture, Riff)).silent).toEqual([
|
||
'C4, C5 are not heard: Lead sounds no note at all. To hear them, get_graph on it shows what it lacks, like the sound of a Sample, or move the notes.',
|
||
]);
|
||
|
||
const sound = addSound(fixture);
|
||
await run(fixture, [
|
||
{
|
||
command: 'set_node_param',
|
||
args: { asset: Lead, node: node('InstrumentSample'), key: 'sound', value: sound },
|
||
},
|
||
]);
|
||
expect((await read(fixture, Riff)).silent).toEqual([
|
||
'C5 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or move the notes.',
|
||
]);
|
||
// An octave down, C5 comes in and C4 drops out: the song goes by the notes as they sound.
|
||
expect((await read(fixture, Theme)).silent).toEqual([
|
||
`Item ${item} plays Riff, transposed by -12. C3 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or change the transpose.`,
|
||
]);
|
||
expect((await read(fixture, Cut)).silent).toEqual([
|
||
expect.stringContaining('Riff, which is not all heard'),
|
||
]);
|
||
|
||
const notes = node('InstrumentNotes');
|
||
await run(fixture, [
|
||
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'from', value: '48' } },
|
||
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'to', value: '72' } },
|
||
]);
|
||
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
|
||
});
|
||
});
|