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>
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
/**
|
|
* The clips of a sound (#357): stretches of its file with a name and a cut of their own, named by a
|
|
* sound field the way a frame is by a texture field. The sound itself is the whole file.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
CreateSoundCommand,
|
|
DuplicateAssetCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import {
|
|
AddClipCommand,
|
|
RemoveClipCommand,
|
|
RenameClipCommand,
|
|
SetClipPropsCommand,
|
|
} from '../../src/core/commands/clipCommands';
|
|
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
|
import type { SoundAsset } from '../../src/core/model/Asset';
|
|
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
|
import { clipsOf, findSound } from '../../src/core/model/sound';
|
|
import { embeddedCopy, embeddedUsedBy } from '../../src/core/music/embedded';
|
|
import { sequenceNeeds } from '../../src/core/music/needs';
|
|
import { itemFx, newItem, songSequence } from '../../src/core/music/song';
|
|
import { assetRefLabel, soundChoices } from '../../src/core/properties/assetRef';
|
|
import { playedSoundById } from '../../src/core/properties/playedSound';
|
|
import { referencedAssetId } from '../../src/core/properties/references';
|
|
import { END_KEY, START_KEY } from '../../src/core/properties/sounds';
|
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
|
import { clipAt } from '../../src/editor/sound/waveView';
|
|
import { makeProject } from '../helpers';
|
|
|
|
const FILE = `${'d5'.repeat(32)}.wav`;
|
|
const BAR = 1920;
|
|
|
|
/** A sound "Kick" with two clips, and a song with a take of its own that has one clip. */
|
|
function setup() {
|
|
const f = makeProject();
|
|
const kick = new CreateSoundCommand('Kick', FILE);
|
|
f.stack.execute(kick);
|
|
const first = new AddClipCommand(kick.asset.id, { [START_KEY]: 0.5, [END_KEY]: 0.75 });
|
|
const second = new AddClipCommand(kick.asset.id, { [START_KEY]: 1 });
|
|
f.stack.execute(first);
|
|
f.stack.execute(second);
|
|
const made = new CreateAssetCommand('song', 'Theme');
|
|
f.stack.execute(made);
|
|
const songId = made.asset.id;
|
|
const song = () => findAssetOf(f.project, songId, 'song')!;
|
|
const take = new CreateSoundCommand('Take 1', FILE, songId);
|
|
f.stack.execute(take);
|
|
const takeClip = new AddClipCommand(take.asset.id, { [START_KEY]: 2, [END_KEY]: 3 });
|
|
f.stack.execute(takeClip);
|
|
const track = song().tracks[0]!.id;
|
|
const item = newItem({ track, kind: 'sound', asset: takeClip.clipId, tick: 0, length: BAR });
|
|
f.stack.execute(new SetSongCommand(songId, { items: [item] }));
|
|
return {
|
|
f,
|
|
song,
|
|
songId,
|
|
track,
|
|
kick: () => findAssetOf(f.project, kick.asset.id, 'sound')!,
|
|
firstId: first.clipId,
|
|
secondId: second.clipId,
|
|
takeId: take.asset.id,
|
|
takeClipId: takeClip.clipId,
|
|
};
|
|
}
|
|
|
|
describe('a clip of a sound (#357)', () => {
|
|
it('is named "Clip 1", 2 … per sound, and resolves to its sound', () => {
|
|
const { f, kick, firstId, secondId, takeId, takeClipId } = setup();
|
|
expect(clipsOf(kick()).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']);
|
|
expect(findSound(f.project, secondId)).toEqual({ asset: kick(), clip: clipsOf(kick())[1] });
|
|
expect(findSound(f.project, kick().id)).toEqual({ asset: kick(), clip: null });
|
|
expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId);
|
|
expect(findSound(f.project, 'nothing')).toBeNull();
|
|
expect(findSound(f.project, f.project.assets.find((a) => a.type === 'song')!.id)).toBeNull();
|
|
expect(firstId).not.toBe(secondId);
|
|
});
|
|
|
|
it('plays its stretch, the sound the whole file', () => {
|
|
const { f, kick, firstId } = setup();
|
|
expect(playedSoundById(f.project, kick().id)).toBe(FILE);
|
|
expect(playedSoundById(f.project, firstId)).toBe(`${FILE}#0.5~0.75~0.005~0.005~linear~linear`);
|
|
});
|
|
|
|
it('reads "Sound / Clip" wherever a field names it, and is offered after its sound', () => {
|
|
const { f, kick, firstId, secondId, takeClipId } = setup();
|
|
expect(assetRefLabel(f.project, firstId)).toBe('Kick / Clip 1');
|
|
expect(assetRefLabel(f.project, takeClipId)).toBe('• Take 1 / Clip 1');
|
|
expect(assetRefLabel(f.project, 'gone')).toBe('gone?');
|
|
// What a song keeps to itself is offered nowhere, nor are its clips.
|
|
expect(soundChoices(f.project)).toEqual([
|
|
{ id: kick().id, label: 'Kick' },
|
|
{ id: firstId, label: 'Kick / Clip 1' },
|
|
{ id: secondId, label: 'Kick / Clip 2' },
|
|
]);
|
|
expect(
|
|
referencedAssetId(f.project, { key: 'sound', label: 'Sound', kind: 'sound', default: '' }, firstId),
|
|
).toBe(kick().id);
|
|
});
|
|
|
|
it('is set, renamed and deleted in steps an undo takes back', () => {
|
|
const { f, kick, firstId, secondId } = setup();
|
|
f.stack.execute(new SetClipPropsCommand(kick().id, firstId, { [START_KEY]: 0.25, [END_KEY]: 0.5 }));
|
|
expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.25, [END_KEY]: 0.5 });
|
|
f.stack.undo();
|
|
expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.5, [END_KEY]: 0.75 });
|
|
|
|
f.stack.execute(new RenameClipCommand(kick().id, secondId, 'Clip 1'));
|
|
expect(clipsOf(kick())[1]!.name).toBe('Clip 1 2');
|
|
f.stack.undo();
|
|
|
|
f.stack.execute(new RemoveClipCommand(kick().id, firstId));
|
|
expect(clipsOf(kick()).map((c) => c.id)).toEqual([secondId]);
|
|
expect(playedSoundById(f.project, firstId)).toBe('');
|
|
f.stack.undo();
|
|
expect(clipsOf(kick()).map((c) => c.id)).toEqual([firstId, secondId]);
|
|
});
|
|
|
|
it('refuses a value a clip cannot hold', () => {
|
|
const { f, kick, firstId } = setup();
|
|
const before = structuredClone(clipsOf(kick()));
|
|
const set = (values: Record<string, string | number>) => () =>
|
|
f.stack.execute(new SetClipPropsCommand(kick().id, firstId, values));
|
|
expect(set({ volume: 1 })).toThrow('A clip has no "volume"');
|
|
expect(set({ [START_KEY]: -1 })).toThrow('Start (s) needs a number of at least 0');
|
|
expect(set({ [START_KEY]: Number.NaN })).toThrow('Start (s) needs a number of at least 0');
|
|
expect(set({ fadeInCurve: 'wobbly' })).toThrow('Fade in curve has no "wobbly"');
|
|
expect(() => f.stack.execute(new RenameClipCommand(kick().id, firstId, ' '))).toThrow(
|
|
'A clip needs a name',
|
|
);
|
|
expect(clipsOf(kick())).toEqual(before);
|
|
});
|
|
|
|
it('survives a round trip; a clip repeating an id the document has is dropped', () => {
|
|
const { f, kick, firstId } = setup();
|
|
expect(deserializeProject(serializeProject(f.project))).toEqual(f.project);
|
|
const raw = serializeProject(f.project) as { assets: { id: string; clips?: unknown[] }[] };
|
|
const stored = raw.assets.find((a) => a.id === kick().id)!;
|
|
stored.clips = [
|
|
...stored.clips!,
|
|
{ id: firstId, name: 'Twin', props: {} },
|
|
{ id: kick().id, name: 'Sound', props: {} },
|
|
{ id: 'odd', name: 'Odd', props: { start: 1, volume: 0.5 } },
|
|
];
|
|
const read = findAssetOf(deserializeProject(raw), kick().id, 'sound')!;
|
|
expect(clipsOf(read).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2', 'Odd']);
|
|
expect(clipsOf(read)[2]!.props).toEqual({ start: 1 });
|
|
});
|
|
|
|
it('gets a fresh id in a duplicate of its sound', () => {
|
|
const { f, kick, firstId, secondId } = setup();
|
|
const duplicate = new DuplicateAssetCommand(kick().id);
|
|
f.stack.execute(duplicate);
|
|
const copy = findAssetOf(f.project, duplicate.copyId, 'sound')!;
|
|
expect(clipsOf(copy).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']);
|
|
expect(clipsOf(copy).map((c) => c.id)).not.toContain(firstId);
|
|
expect(clipsOf(copy).map((c) => c.id)).not.toContain(secondId);
|
|
expect(findSound(f.project, firstId)?.asset.id).toBe(kick().id);
|
|
});
|
|
});
|
|
|
|
describe('a clip in a song (#357)', () => {
|
|
it('is played by its item, through the FX of its sound', () => {
|
|
const { f, song, takeClipId } = setup();
|
|
const take = findSound(f.project, takeClipId)!.asset;
|
|
take.props.fx = 'some-fx';
|
|
expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeClipId]);
|
|
expect(sequenceNeeds(f.project, songSequence(f.project, song())).files).toEqual([
|
|
`${FILE}#2~3~0.005~0.005~linear~linear`,
|
|
]);
|
|
expect(itemFx(f.project, song().items[0]!)).toBe('some-fx');
|
|
});
|
|
|
|
it('keeps its take, which a second item shares, on the same clip or the whole take', () => {
|
|
const { f, song, songId, track, takeId, takeClipId } = setup();
|
|
expect(embeddedUsedBy(f.project, song().items).map((a) => a.id)).toEqual([takeId]);
|
|
const before = f.project.assets.length;
|
|
const [item] = song().items;
|
|
const second = { ...item!, id: 'second', tick: BAR };
|
|
f.stack.execute(new SetSongCommand(songId, { items: [item!, second] }));
|
|
expect(song().items.map((i) => i.asset)).toEqual([takeClipId, takeClipId]);
|
|
f.stack.execute(new SetSongCommand(songId, { items: [item!, { ...second, asset: takeId }] }));
|
|
expect(song().items.map((i) => i.asset)).toEqual([takeClipId, takeId]);
|
|
expect(f.project.assets.length).toBe(before);
|
|
expect(song().items.map((i) => i.track)).toEqual([track, track]);
|
|
});
|
|
|
|
it('is copied along when an item is given a copy of the sound or the song is duplicated', () => {
|
|
const { f, song, kick, secondId, takeClipId } = setup();
|
|
const { copy, ids } = embeddedCopy(kick(), song());
|
|
const copied = clipsOf(copy as SoundAsset);
|
|
expect(ids.get(secondId)).toBe(copied[1]!.id);
|
|
expect(ids.get(kick().id)).toBe(copy.id);
|
|
|
|
const duplicate = new DuplicateAssetCommand(song().id);
|
|
f.stack.execute(duplicate);
|
|
const twin = findAssetOf(f.project, duplicate.copyId, 'song')!;
|
|
const named = twin.items[0]!.asset;
|
|
expect(named).not.toBe(takeClipId);
|
|
const found = findSound(f.project, named)!;
|
|
expect([found.asset.owner, found.clip?.name]).toEqual([twin.id, 'Clip 1']);
|
|
expect(findAsset(f.project, found.asset.id)).toBe(found.asset);
|
|
});
|
|
});
|
|
|
|
describe('the strip of the Sound tab (#357)', () => {
|
|
it('picks the band under the pointer, the one drawn last where they lie over each other', () => {
|
|
const view = { panX: 0, pxPerSecond: 100 };
|
|
const bands = [
|
|
{ id: 'a', span: { start: 0, end: 1 } },
|
|
{ id: 'b', span: { start: 0.5, end: 2 } },
|
|
];
|
|
expect([10, 60, 150, 250].map((x) => clipAt(view, bands, x))).toEqual(['a', 'b', 'b', null]);
|
|
});
|
|
});
|