A song had two rows for its meter, Beats per bar and Beat, and the patterns it kept to itself took both from it on every change. Their length is counted in bars, so a new sign cut or stretched them and their items. - The song has one row Sign with 1/2, 2/2, 3/2, 1/4 to 6/4 and 5/8 to 9/8, 4/4 by default. It is stored as `sign`. A value not in the list reads as 4/4. - An embedded pattern keeps its own tempo and meter, as a project pattern does. Its inspector shows Tempo, Beats per bar and Beat again and hides only Bus. - A pattern made in a song still starts with the song's tempo and sign, so its first bar fits the item it gets. - A copy (Embed a copy, a paste from another song) keeps its clock. `clockFollowers` and the follow step in `AssetPropertyCommand` are gone. - No load migration. The two songs of the user's project were moved from `beats`/`beat` to `sign` by hand. No other project had a song with a meter of its own. Tests: - Unit song + embedded + pattern: 44 green, 1 new, 4 rewritten. Counter-probes: a copy taking the song's clock and a sign outside the list both turn a test red. - e2e 65 (melektaus-atx): ALL PASSED, 57 checks (1 new: Sign picked from its list in the inspector). - Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4214 tests. After it, `SIGNS` lost its `as const` and the cast went with it. Typecheck, lint and the 44 unit tests ran again, all green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
16 KiB
TypeScript
366 lines
16 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', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
|
||
true,
|
||
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 only its bus, not its clock, length, instrument or sound FX (#363)', () => {
|
||
const { make } = withPatterns();
|
||
const own = make();
|
||
const keys = ['bus', 'tempo', 'beats', 'beat', 'bars', 'instrument', 'fx'];
|
||
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([
|
||
true,
|
||
false,
|
||
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, beats: 5 }, 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,
|
||
]);
|
||
});
|
||
});
|