Files
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00

357 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a song keeps to itself (#355): an asset with the song as its owner, left out of every list,
* shared by the places of the song (#357). A paste from another song gives it a copy of its own;
* what nothing names goes in the same step, and comes back with an undo.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
DeleteAssetCommand,
DuplicateAssetCommand,
MakeProjectAssetCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { nextAssetName } from '../../src/core/commands/naming';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { Asset, PatternAsset, SongAsset } from '../../src/core/model/Asset';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import {
assetTitle,
embeddedCopy,
embeddedIn,
embeddedName,
embeddedUsedBy,
instrumentNear,
isRowHiddenWhenEmbedded,
listedAssets,
songClockProps,
} from '../../src/core/music/embedded';
import { patternLength, patternMeter, patternTempo } from '../../src/core/music/pattern';
import { newItem, type SongItem, songMeter, songSequence } from '../../src/core/music/song';
import { assetRefChoices } from '../../src/core/properties/assetRef';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { makeProject } from '../helpers';
const FILE = `${'c4'.repeat(32)}.wav`;
const BAR = 1920;
/** A song "Theme" with a take of its own on an item, and a project sound "Blip". */
function setup() {
const f = makeProject();
const made = new CreateAssetCommand('song', 'Theme');
f.stack.execute(made);
const songId = made.asset.id;
const song = () => findAssetOf(f.project, songId, 'song')!;
const blip = new CreateSoundCommand('Blip', FILE);
f.stack.execute(blip);
const take = new CreateSoundCommand('Take 1', FILE, songId);
f.stack.execute(take);
const track = song().tracks[0]!.id;
const item = newItem({ track, kind: 'sound', asset: take.asset.id, tick: 0, length: 2 * BAR });
f.stack.execute(new SetSongCommand(songId, { items: [item] }));
const edit = (items: SongItem[], extra: { spare?: Asset[] } = {}) =>
f.stack.execute(new SetSongCommand(songId, { items, ...extra }));
return { f, songId, song, track, blipId: blip.asset.id, takeId: take.asset.id, itemId: item.id, edit };
}
const ids = (assets: readonly Asset[]) => assets.map((a) => a.id);
describe('an embedded asset (#355)', () => {
it('is found by its id but left out of every list and of the names', () => {
const { f, songId, takeId, blipId, song } = setup();
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
expect(ids(listedAssets(f.project))).not.toContain(takeId);
expect(ids(assetRefChoices(f.project, ['sound']))).toEqual([blipId]);
expect(nextAssetName(f.project, 'Take 1')).toBe('Take 1');
expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeId]);
expect(assetTitle(f.project, findAsset(f.project, takeId)!)).toBe('Theme › Take 1');
});
it('hides the rows that do nothing in a song, only while embedded', () => {
const { f, takeId, blipId } = setup();
const take = findAsset(f.project, takeId)!;
expect(['loop', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
true,
false,
false,
]);
expect(isRowHiddenWhenEmbedded(findAsset(f.project, blipId)!, 'loop')).toBe(false);
});
it('is shared by both halves of a split (#357), and stays until the last place lets go', () => {
const { f, song, takeId, edit } = setup();
const before = f.project.assets.length;
const left = song().items[0]!;
edit([
{ ...left, length: BAR },
{ ...left, id: 'right', tick: BAR, length: BAR },
]);
expect(song().items.map((i) => i.asset)).toEqual([takeId, takeId]);
expect(f.project.assets.length).toBe(before);
edit([song().items[1]!]);
expect(findAsset(f.project, takeId)?.owner).toBe(song().id);
edit([]);
expect(findAsset(f.project, takeId)).toBeUndefined();
f.stack.undo();
expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]);
});
it('is shared by the song, its tracks and its items, whichever kind (#357)', () => {
const { f, song, songId, track, edit } = setup();
const fx = new CreateAssetCommand('soundfx', 'Sound FX 1', {}, songId);
const pattern = new CreateAssetCommand('pattern', 'Pattern 1', {}, songId);
f.stack.execute(fx);
f.stack.execute(pattern);
const tracks = song().tracks.map((t) => ({ ...t, fx: fx.asset.id }));
const played = { track, kind: 'pattern' as const, asset: pattern.asset.id, length: BAR };
const items = [0, BAR].map((tick) => ({ ...newItem({ ...played, tick }), fx: fx.asset.id }));
f.stack.execute(new SetSongCommand(songId, { tracks, items }));
f.stack.execute(new SetAssetPropertyCommand(songId, 'fx', fx.asset.id));
expect(song().items.map((i) => [i.asset, i.fx])).toEqual([
[pattern.asset.id, fx.asset.id],
[pattern.asset.id, fx.asset.id],
]);
expect(song().tracks[0]!.fx).toBe(fx.asset.id);
expect(
embeddedIn(f.project, songId)
.map((a) => a.type)
.sort(),
).toEqual(['pattern', 'soundfx']);
f.stack.execute(new SetAssetPropertyCommand(songId, 'fx', ''));
edit(song().items.map((i) => ({ ...i, fx: '' })));
expect(findAsset(f.project, fx.asset.id)?.owner).toBe(songId);
f.stack.execute(new SetSongCommand(songId, { tracks: song().tracks.map((t) => ({ ...t, fx: '' })) }));
expect(findAsset(f.project, fx.asset.id)).toBeUndefined();
});
it('goes with the item that names it, and comes back on undo where it was', () => {
const { f, songId, takeId, edit } = setup();
const at = f.project.assets.findIndex((a) => a.id === takeId);
edit([]);
expect(findAsset(f.project, takeId)).toBeUndefined();
f.stack.undo();
expect(f.project.assets.findIndex((a) => a.id === takeId)).toBe(at);
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
});
it('comes back from what was copied along when a paste names it after it went', () => {
const { f, song, takeId, edit } = setup();
const copied = song().items.map((i) => ({ ...i }));
const spare = structuredClone(embeddedUsedBy(f.project, copied));
expect(ids(spare)).toEqual([takeId]);
edit([]);
edit(
copied.map((i) => ({ ...i, id: 'pasted' })),
{ spare },
);
expect(song().items[0]!.asset).toBe(takeId);
expect(findAsset(f.project, takeId)?.owner).toBe(song().id);
});
it('is copied for a song that names one of another song', () => {
const { f, song, takeId } = setup();
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const otherSong = other.asset as SongAsset;
const item = newItem({
track: otherSong.tracks[0]!.id,
kind: 'sound',
asset: takeId,
tick: 0,
length: BAR,
});
f.stack.execute(new SetSongCommand(otherSong.id, { items: [item] }));
const named = findAssetOf(f.project, otherSong.id, 'song')!.items[0]!.asset;
expect(named).not.toBe(takeId);
expect(findAsset(f.project, named)?.owner).toBe(otherSong.id);
expect(findAsset(f.project, takeId)?.owner).toBe(song().id);
});
it('is copied once for all the items of another song that name it (#357)', () => {
const { f, takeId } = setup();
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const otherSong = other.asset as SongAsset;
const track = otherSong.tracks[0]!.id;
const pasted = [0, BAR].map((tick) =>
newItem({ track, kind: 'sound', asset: takeId, tick, length: BAR }),
);
f.stack.execute(new SetSongCommand(otherSong.id, { items: pasted }));
const named = findAssetOf(f.project, otherSong.id, 'song')!.items.map((i) => i.asset);
expect(named[0]).not.toBe(takeId);
expect(named[1]).toBe(named[0]);
expect(embeddedIn(f.project, otherSong.id).map((a) => a.id)).toEqual([named[0]]);
});
it('leaves a project asset named by several items alone', () => {
const { f, song, blipId, edit, track } = setup();
const before = f.project.assets.length;
edit([
newItem({ track, kind: 'sound', asset: blipId, tick: 0, length: BAR }),
newItem({ track, kind: 'sound', asset: blipId, tick: BAR, length: BAR }),
]);
expect(song().items.map((i) => i.asset)).toEqual([blipId, blipId]);
expect(f.project.assets.length).toBe(before - 1);
});
it('is embedded as a copy of a project asset, which stays', () => {
const { f, song, blipId, edit, track } = setup();
const { copy } = embeddedCopy(findAsset(f.project, blipId)!, song());
edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] });
expect(song().items[0]!.asset).toBe(copy.id);
expect(findAsset(f.project, copy.id)?.owner).toBe(song().id);
expect(findAsset(f.project, blipId)?.owner).toBeUndefined();
});
it('goes with its song, and comes back with it', () => {
const { f, songId, takeId } = setup();
f.stack.execute(new DeleteAssetCommand(songId));
expect(findAsset(f.project, takeId)).toBeUndefined();
f.stack.undo();
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
expect(findAsset(f.project, songId)).toBeDefined();
});
it('is copied for a duplicate of its song, which names the copy', () => {
const { f, songId, takeId } = setup();
const dup = new DuplicateAssetCommand(songId);
f.stack.execute(dup);
const copy = findAssetOf(f.project, dup.copyId, 'song')!;
const named = copy.items[0]!.asset;
expect(named).not.toBe(takeId);
expect(findAsset(f.project, named)?.owner).toBe(copy.id);
expect(embeddedIn(f.project, songId).map((a) => a.id)).toEqual([takeId]);
f.stack.undo();
expect(findAsset(f.project, named)).toBeUndefined();
});
it('becomes a project asset of a free name, and back on undo', () => {
const { f, songId, takeId } = setup();
f.stack.execute(new CreateSoundCommand('Take 1', FILE));
f.stack.execute(new MakeProjectAssetCommand(takeId));
const take = findAsset(f.project, takeId)!;
expect([take.owner, take.name]).toEqual([undefined, 'Take 1 2']);
expect(ids(listedAssets(f.project))).toContain(takeId);
f.stack.undo();
expect([take.owner, take.name]).toEqual([songId, 'Take 1']);
});
it('a duplicate of it is a project asset', () => {
const { f, takeId } = setup();
const dup = new DuplicateAssetCommand(takeId);
f.stack.execute(dup);
expect(findAsset(f.project, dup.copyId)?.owner).toBeUndefined();
});
it('keeps its owner through a save, and is a project asset once its song is gone', () => {
const { f, songId, takeId } = setup();
const raw = serializeProject(f.project) as { assets: Record<string, unknown>[] };
expect(deserializeProject(raw).assets.find((a) => a.id === takeId)?.owner).toBe(songId);
const orphan = { ...raw, assets: raw.assets.filter((a) => a.id !== songId) };
expect(deserializeProject(orphan).assets.find((a) => a.id === takeId)?.owner).toBeUndefined();
const sprite = {
...raw,
assets: [...raw.assets, { id: 'sp', type: 'sprite', name: 'S', props: {}, owner: songId }],
};
expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined();
});
});
describe('a pattern made in a song (#355)', () => {
/** The song of `setup` at 140 BPM in 3/4, with a project pattern "Loose" and two instruments. */
function withPatterns() {
const s = setup();
const { f, songId } = s;
f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 140));
f.stack.execute(new SetAssetPropertyCommand(songId, 'sign', '3/4'));
const loose = new CreateAssetCommand('pattern', 'Loose');
f.stack.execute(loose);
const lead = new CreateAssetCommand('instrument', 'Lead');
const bass = new CreateAssetCommand('instrument', 'Bass');
f.stack.execute(lead);
f.stack.execute(bass);
const make = (props: Record<string, string | number> = {}) => {
const made = new CreateAssetCommand(
'pattern',
embeddedName(f.project, songId, 'Pattern'),
{ ...songClockProps(s.song().props), bars: 1, ...props },
songId,
);
f.stack.execute(made);
return made.asset as PatternAsset;
};
return { ...s, loose: loose.asset as PatternAsset, leadId: lead.asset.id, bassId: bass.asset.id, make };
}
it('is a bar of the song on its clock, numbered among what the song keeps', () => {
const { f, songId, make } = withPatterns();
f.stack.execute(new CreateAssetCommand('pattern', 'Pattern 1'));
const first = make();
expect([first.name, first.owner, patternTempo(first.props)]).toEqual(['Pattern 1', songId, 140]);
expect(patternMeter(first.props)).toEqual({ beats: 3, unit: 4 });
expect(patternLength(first)).toBe(3 * 480);
expect(make().name).toBe('Pattern 2');
});
it('keeps its own tempo, meter and length when the song changes its own (#363)', () => {
const { f, songId, make } = withPatterns();
const own = make();
f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 90));
f.stack.execute(new SetAssetPropertyCommand(songId, 'sign', '6/8'));
expect(songMeter(findAsset(f.project, songId)!.props)).toEqual({ beats: 6, unit: 8 });
expect([patternTempo(own.props), patternMeter(own.props), patternLength(own)]).toEqual([
140,
{ beats: 3, unit: 4 },
3 * 480,
]);
});
it('hides none of its rows: not its clock, length, instrument or sound FX (#363)', () => {
const { make } = withPatterns();
const own = make();
const keys = ['tempo', 'sign', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([false, false, false, false, false]);
});
it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => {
const { f, track, edit, song, takeId, leadId, bassId, make } = withPatterns();
const withLead = make({ instrument: leadId });
const other = make();
const items = [
...song().items,
newItem({ track, kind: 'pattern', asset: withLead.id, tick: BAR, length: BAR }),
{
...newItem({ track, kind: 'pattern', asset: other.id, tick: 3 * BAR, length: BAR }),
instrument: bassId,
},
newItem({ track, kind: 'sound', asset: takeId, tick: 4 * BAR, length: BAR }),
];
edit(items);
const near = (tick: number, on = track) => instrumentNear(f.project, song().items, on, tick);
expect([near(0), near(2 * BAR), near(3 * BAR), near(5 * BAR)]).toEqual([leadId, leadId, bassId, bassId]);
expect(near(0, 'elsewhere')).toBe('');
});
it("keeps its own clock as a copy, a project pattern's or another song's (#363)", () => {
const { f, song, loose, track, edit } = withPatterns();
const copy = embeddedCopy(loose, song()).copy as PatternAsset;
expect([patternTempo(copy.props), patternMeter(copy.props).beats]).toEqual([120, 4]);
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1, sign: '5/4' }, other.asset.id);
f.stack.execute(theirs);
edit([
...song().items,
newItem({ track, kind: 'pattern', asset: theirs.asset.id, tick: 0, length: BAR }),
]);
const named = findAssetOf(f.project, song().items.at(-1)!.asset, 'pattern')!;
expect(named.id).not.toBe(theirs.asset.id);
expect([named.owner, patternTempo(named.props), patternMeter(named.props).beats]).toEqual([
song().id,
120,
5,
]);
});
});