Files
feuerwerk/tests/core/embedded.test.ts
T
adminandClaude Opus 5.5 42a3759d9d Cut a sound into named clips, as a texture into frames (#357)
A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.

- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
  generator with asset ids, so a sound field holds a sound or a clip,
  as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
  Volume, loop, sound FX and bus stay on the sound for all its clips.
  The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
  instrument, a Reverb impulse, song items, the scheduler and the test
  sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
  "Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
  A drag there makes a clip, a click picks one, beside the bands the
  sound. Only the picked clip has handles. Reverse, fades and curves
  in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
  inspector shows its name, its cut rows and Delete clip. Delete
  takes it away without asking, as with a frame. Ctrl+Z brings it
  back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
  duplicated with its takes, and the copy a split or paste makes of
  an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
  the clip picked. The Sound row of an item on an embedded take
  offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
  user decided.
- No migration in the code, as the user decided. The one project
  with cut sounds was changed over by hand.

Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".

Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
  the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests

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

333 lines
14 KiB
TypeScript
Raw 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,
* 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,
ResetAssetPropertyCommand,
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, 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());
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, 'beats', 3));
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('follows the tempo and the meter of its song in the same undo step, and nothing else', () => {
const { f, songId, loose, make } = withPatterns();
const own = make();
f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 90));
expect([patternTempo(own.props), patternTempo(loose.props)]).toEqual([90, 120]);
f.stack.execute(new SetAssetPropertyCommand(songId, 'beat', '8'));
expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 });
f.stack.execute(new ResetAssetPropertyCommand(songId, 'beats'));
expect(patternMeter(own.props).beats).toBe(4);
const grid = own.props.grid;
f.stack.execute(new SetAssetPropertyCommand(songId, 'grid', '1/8'));
expect([own.props.grid, findAsset(f.project, songId)?.props.grid]).toEqual([grid, '1/8']);
f.stack.undo();
f.stack.undo();
expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 });
f.stack.undo();
f.stack.undo();
expect([patternTempo(own.props), patternMeter(own.props).unit]).toEqual([140, 4]);
});
it('hides its clock and its bus, not its length, instrument or sound FX', () => {
const { loose, make } = withPatterns();
const own = make();
const keys = ['tempo', 'beats', 'beat', 'bus', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([
true,
true,
true,
true,
false,
false,
false,
]);
expect(isRowHiddenWhenEmbedded(loose, 'tempo')).toBe(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("takes the clock of the song it comes to as a copy, a project pattern's or another song's", () => {
const { f, song, loose, track, edit } = withPatterns();
const copy = embeddedCopy(loose, song()).copy as PatternAsset;
expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]);
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1 }, 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,
140,
3,
]);
});
});