Files
feuerwerk/tests/core/sound-clips.test.ts
T
adminandClaude Opus 5.5 e721bba120
game-maker / gate (push) Successful in 3m54s
game-maker / suite (push) Successful in 15m58s
Let the places of a song share what it keeps to itself (#357)
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>
2026-10-08 21:13:22 +02:00

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]);
});
});