Files
feuerwerk/tests/core/sound-clips.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

220 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, and a second item on a clip of it gets a copy with its own clip ids', () => {
const { f, song, songId, track, takeId, takeClipId } = setup();
expect(embeddedUsedBy(f.project, song().items).map((a) => a.id)).toEqual([takeId]);
const [item] = song().items;
f.stack.execute(new SetSongCommand(songId, { items: [item!, { ...item!, id: 'second', tick: BAR }] }));
const named = song().items[1]!.asset;
const copy = findSound(f.project, named)!;
expect(copy.asset.id).not.toBe(takeId);
expect(copy.asset.owner).toBe(songId);
expect(copy.clip?.name).toBe('Clip 1');
expect(named).not.toBe(takeClipId);
expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId);
// The take nothing else names stays.
expect(song().items[0]!.asset).toBe(takeClipId);
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]);
});
});