With clips, one take serves many items: four items on the four clips of one take. The rule from #355 gave every further place its own copy of an embedded asset, so a cut made in the Sound tab reached one item only. Now the song, its tracks and its items share a sound, a pattern or a sound FX the song keeps. - settleEmbedded no longer copies within the song. An embedded asset stays as long as one place names it, and goes with the last one. - A paste from another song still gets a copy, but one copy for all the places naming the same asset, with its clip ids mapped. - The picks in a song offer what the song keeps, under a line after the project's assets: the Sound row of an item the song's sounds and their clips, the Pattern row its patterns, every sound FX pick its sound FX. - Make project asset and Embed a copy are unchanged. The project cbbf160bb7e8 was merged by hand afterwards: the items of Song 2 on three copies of Take 1 now play the same clips of the first one, and the copies are gone. Tests: - Unit embedded, sound-clips, own-fx and the song tests: 66 green, 3 counter-checks red as expected - e2e 65 on melektaus-atx: ALL PASSED, 52 checks - Gate on melektaus-atx: CHECK EXIT 0, 271 files, 4198 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
7.0 KiB
TypeScript
140 lines
7.0 KiB
TypeScript
/**
|
|
* A sound FX of its own for a song, a track or an item (#355): made from the place's pick, kept by
|
|
* the song, gone with the place letting go of it, all in one undo step each.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
CreateSoundCommand,
|
|
DuplicateAssetCommand,
|
|
ResetAssetPropertyCommand,
|
|
SetAssetPropertyCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
|
import type { SoundFxAsset } from '../../src/core/model/Asset';
|
|
import { createEmptyProject, findAsset, findAssetOf } from '../../src/core/model/Project';
|
|
import { embeddedIn, listedAssets } from '../../src/core/music/embedded';
|
|
import { newItem, type SongItem, type SongTrack } from '../../src/core/music/song';
|
|
import { ASSET_FX_KEY } from '../../src/core/properties/assetFx';
|
|
import { assetRefLabel } from '../../src/core/properties/assetRef';
|
|
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
|
import { createOwnFx, type FxPlace, ownFxEntries } from '../../src/editor/song/ownFx';
|
|
import { EditorState } from '../../src/editor/state';
|
|
import type { MenuEntry } from '../../src/ui';
|
|
|
|
const FILE = `${'c4'.repeat(32)}.wav`;
|
|
|
|
/** A song "Theme" with a project sound "Blip" on an item and a project sound FX "Echo". */
|
|
function setup() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const song = new CreateAssetCommand('song', 'Theme');
|
|
const echo = new CreateAssetCommand('soundfx', 'Echo');
|
|
const blip = new CreateSoundCommand('Blip', FILE);
|
|
for (const c of [song, echo, blip]) expect(state.execute(c)).toBe(true);
|
|
const songId = song.asset.id;
|
|
const theme = () => findAssetOf(state.project, songId, 'song')!;
|
|
const trackId = theme().tracks[0]!.id;
|
|
const item = newItem({ track: trackId, kind: 'sound', asset: blip.asset.id, tick: 0, length: 1920 });
|
|
state.execute(new SetSongCommand(songId, { items: [item] }));
|
|
const track = () => theme().tracks.find((t) => t.id === trackId)!;
|
|
const onTrack: FxPlace = (fx) =>
|
|
new SetSongCommand(songId, {
|
|
tracks: theme().tracks.map((t): SongTrack => (t.id === trackId ? { ...t, fx } : t)),
|
|
});
|
|
const onItem: FxPlace = (fx) =>
|
|
new SetSongCommand(songId, { items: theme().items.map((i): SongItem => ({ ...i, fx })) });
|
|
const onSong: FxPlace = (fx) => new SetAssetPropertyCommand(songId, ASSET_FX_KEY, fx);
|
|
return { state, songId, theme, track, echoId: echo.asset.id, onTrack, onItem, onSong };
|
|
}
|
|
|
|
const labels = (entries: MenuEntry[]) => entries.flatMap((e) => ('label' in e ? [e.label] : []));
|
|
|
|
describe('a sound FX of its own in a song (#355)', () => {
|
|
it('is made for a track in one step, lets the sound through and is opened', () => {
|
|
const { state, songId, track, onTrack } = setup();
|
|
expect(labels(ownFxEntries(state, songId, '', onTrack))).toEqual(['Create sound FX']);
|
|
const id = createOwnFx(state, songId, onTrack)!;
|
|
const fx = findAssetOf(state.project, id, 'soundfx')!;
|
|
expect([track().fx, fx.owner, fx.name]).toEqual([id, songId, 'Sound FX 1']);
|
|
expect(compileSoundFx(fx.graph)).toMatchObject({ reason: '', effects: 0 });
|
|
expect(listedAssets(state.project).map((a) => a.id)).not.toContain(id);
|
|
expect(state.selection).toEqual({ kind: 'asset', assetId: id });
|
|
expect(assetRefLabel(state.project, id)).toBe('• Sound FX 1');
|
|
state.undo();
|
|
expect([track().fx, findAsset(state.project, id)]).toEqual(['', undefined]);
|
|
});
|
|
|
|
it('is offered to edit or to make a project asset while the place names it', () => {
|
|
const { state, songId, onTrack } = setup();
|
|
const id = createOwnFx(state, songId, onTrack)!;
|
|
expect(labels(ownFxEntries(state, songId, id, onTrack))).toEqual([
|
|
'• Sound FX 1',
|
|
'Edit sound FX',
|
|
'Make project asset',
|
|
]);
|
|
expect(createOwnFx(state, songId, onTrack)).not.toBeNull();
|
|
expect(embeddedIn(state.project, songId).map((a) => a.name)).toEqual(['Sound FX 2']);
|
|
});
|
|
|
|
it('is offered to every place of its song, which share it (#357)', () => {
|
|
const { state, songId, theme, track, onTrack, onItem } = setup();
|
|
const id = createOwnFx(state, songId, onTrack)!;
|
|
const entries = ownFxEntries(state, songId, '', onItem);
|
|
expect(labels(entries)).toEqual(['• Sound FX 1', 'Create sound FX']);
|
|
const own = entries.find((e) => 'label' in e && e.label === '• Sound FX 1');
|
|
if (own && 'onSelect' in own) own.onSelect();
|
|
expect([track().fx, theme().items[0]!.fx]).toEqual([id, id]);
|
|
state.execute(onTrack(''));
|
|
expect(findAsset(state.project, id)?.owner).toBe(songId);
|
|
});
|
|
|
|
it('goes when its track picks another one, and comes back on undo', () => {
|
|
const { state, songId, track, echoId, onTrack } = setup();
|
|
const id = createOwnFx(state, songId, onTrack)!;
|
|
state.execute(onTrack(echoId));
|
|
expect([track().fx, findAsset(state.project, id)]).toEqual([echoId, undefined]);
|
|
state.undo();
|
|
expect([track().fx, findAsset(state.project, id)?.owner]).toEqual([id, songId]);
|
|
});
|
|
|
|
it('of the song itself stays through edits of the song and goes once it picks another', () => {
|
|
const { state, songId, theme, onSong, onTrack } = setup();
|
|
const id = createOwnFx(state, songId, onSong)!;
|
|
expect(theme().props[ASSET_FX_KEY]).toBe(id);
|
|
createOwnFx(state, songId, onTrack);
|
|
expect(findAsset(state.project, id)?.owner).toBe(songId);
|
|
state.execute(new SetAssetPropertyCommand(songId, ASSET_FX_KEY, ''));
|
|
expect(findAsset(state.project, id)).toBeUndefined();
|
|
state.undo();
|
|
expect(findAsset(state.project, id)?.owner).toBe(songId);
|
|
state.execute(new ResetAssetPropertyCommand(songId, ASSET_FX_KEY));
|
|
expect(findAsset(state.project, id)).toBeUndefined();
|
|
expect(embeddedIn(state.project, songId).map((a) => a.name)).toEqual(['Sound FX 2']);
|
|
});
|
|
|
|
it('is a copy of a project FX for an item to bend, leaving the original alone', () => {
|
|
const { state, songId, theme, echoId, onItem } = setup();
|
|
state.execute(onItem(echoId));
|
|
const echo = findAssetOf(state.project, echoId, 'soundfx')!;
|
|
expect(labels(ownFxEntries(state, songId, echoId, onItem))).toEqual(['Create sound FX', 'Embed a copy']);
|
|
const id = createOwnFx(state, songId, onItem, echo)!;
|
|
const copy = findAsset(state.project, id) as SoundFxAsset;
|
|
expect([theme().items[0]!.fx, copy.owner, copy.name]).toEqual([id, songId, 'Echo']);
|
|
expect(copy.graph).toEqual(echo.graph);
|
|
expect(copy.graph).not.toBe(echo.graph);
|
|
expect(echo.owner).toBeUndefined();
|
|
});
|
|
|
|
it('of the song and of a track is copied along with a duplicate of the song', () => {
|
|
const { state, songId, onSong, onTrack } = setup();
|
|
const own = [createOwnFx(state, songId, onSong)!, createOwnFx(state, songId, onTrack)!];
|
|
const dup = new DuplicateAssetCommand(songId);
|
|
state.execute(dup);
|
|
const copy = findAssetOf(state.project, dup.copyId, 'song')!;
|
|
const named = [copy.props[ASSET_FX_KEY] as string, copy.tracks[0]!.fx];
|
|
expect(named.some((id) => own.includes(id))).toBe(false);
|
|
expect(named.map((id) => findAsset(state.project, id)?.owner)).toEqual([dup.copyId, dup.copyId]);
|
|
});
|
|
});
|