Keep a recorded take inside its song (#355)

A take used to land in the Assets panel as "Song – Take N", next to
every other sound of the project. Now a song can keep a sound, a
pattern or a sound FX to itself: an ordinary asset whose new field
`owner` names the song. Everything that finds an asset by its id
finds it, so playing, the tabs and the game need no change. Only
the places that list assets leave it out.

- `core/music/embedded.ts` holds the rules: which types can be
  embedded, `listedAssets`, the title "Song › Take 1", and the
  rows an embedded sound hides (Loop, Bus).
- One user per embedded asset. `SetSongCommand` settles the song
  after every edit: a place naming an asset an earlier place
  claimed, or one of another song, gets a copy, and what nothing
  names any more goes in the same undo step. The outcome is worked
  out once, so a redo brings back the same copies.
- A split, Ctrl+D and a paste get that for free. The clipboard
  keeps the embedded assets of the copied items, so a paste after a
  delete brings the take back.
- Deleting a song deletes what it keeps. Duplicating it copies
  that and points the copy at the copies. A duplicate of an
  embedded asset is a project asset.
- The item menu has Open, Make project asset and Embed a copy.
- An embedded item carries a bullet in front of its name. The
  first pick was a diamond, which the gate's font test refused:
  the UI font has no glyph for it.
- A take is "Take 1", 2, … numbered per song. With the song gone
  while it uploads, it becomes a project sound under a free name.
- Left out of: the Assets panel, every asset picker, the sound FX
  menu of a track, the test sound of the Sound FX tab and the
  naming of new assets. Delete on an embedded asset only says
  where it lives. A ping lights up its song.
- Loading drops an `owner` whose song is gone, the asset stays as
  a project asset. No migration: older projects have no owner.

Tests:
- Unit embedded (new): 13 green, 6 counter-checks each red
- Unit take, song, song-edit, asset-fx, asset-ref, serialize and
  neighbours: 116 green
- e2e 65 (melektaus-atx): ALL PASSED, 47 checks (1 new)
- Counter-check e2e 65 (asset panel unfiltered): exactly that
  check red
- Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4167 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 10:36:25 +02:00
1 parent 7a17030472
commit 23cb34f781
18 files changed
+565 -52

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
/**
* What a song keeps to itself (#355): an asset with the song as its owner, left out of every list,
* with one user in the song. Split, duplicate and paste give a place 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,
} from '../../src/core/commands/assetCommands';
import { nextAssetName } from '../../src/core/commands/naming';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { Asset, SongAsset } from '../../src/core/model/Asset';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import {
assetTitle,
embeddedCopy,
embeddedIn,
embeddedUsedBy,
isRowHiddenWhenEmbedded,
listedAssets,
} from '../../src/core/music/embedded';
import { newItem, type SongItem, 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('gives the right half of a split a copy of its own, the same one again on redo', () => {
const { f, song, takeId, edit } = setup();
const left = song().items[0]!;
edit([
{ ...left, length: BAR },
{ ...left, id: 'right', tick: BAR, length: BAR },
]);
const [a, b] = song().items;
expect(a!.asset).toBe(takeId);
expect(b!.asset).not.toBe(takeId);
const copy = findAsset(f.project, b!.asset)!;
expect([copy.owner, copy.name, copy.type]).toEqual([song().id, 'Take 1', 'sound']);
f.stack.undo();
expect(findAsset(f.project, copy.id)).toBeUndefined();
expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]);
f.stack.redo();
expect(song().items[1]!.asset).toBe(copy.id);
expect(findAsset(f.project, copy.id)).toBe(copy);
});
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('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().id);
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();
});
});