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>
357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
/**
|
||
* 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,
|
||
]);
|
||
});
|
||
});
|