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>
This commit is contained in:
1 parent
c7fa1d15b0
commit
42a3759d9d
32 files changed
+1230
-363
No files matched your search
@@ -157,7 +157,7 @@ describe('an embedded asset (#355)', () => {
|
||||
|
||||
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());
|
||||
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);
|
||||
@@ -311,7 +311,7 @@ describe('a pattern made in a song (#355)', () => {
|
||||
|
||||
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()) as PatternAsset;
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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]);
|
||||
});
|
||||
});
|
||||
+25
-18
@@ -9,6 +9,7 @@ import {
|
||||
CreateSoundCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands';
|
||||
import type { BehaviorAsset, SoundAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { playedSoundById, playedSoundOf } from '../../src/core/properties/playedSound';
|
||||
import {
|
||||
CLIP_ROWS,
|
||||
cutOf,
|
||||
END_KEY,
|
||||
FADE_IN_CURVE_KEY,
|
||||
@@ -128,9 +130,9 @@ describe('the sound asset', () => {
|
||||
expect(asset).toMatchObject({ type: 'sound', file: '' });
|
||||
expect(volumeOf(asset.props)).toBe(1);
|
||||
expect(loopsOf(asset.props)).toBe(false);
|
||||
expect(schemaFor('sound').map((def) => def.key)).toEqual([
|
||||
VOLUME_KEY,
|
||||
LOOP_KEY,
|
||||
// The cut belongs to a clip of the sound (#357), the sound is the whole file.
|
||||
expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx', BUS_KEY]);
|
||||
expect(CLIP_ROWS.map((def) => def.key)).toEqual([
|
||||
START_KEY,
|
||||
END_KEY,
|
||||
REVERSE_KEY,
|
||||
@@ -138,10 +140,8 @@ describe('the sound asset', () => {
|
||||
FADE_IN_CURVE_KEY,
|
||||
FADE_OUT_KEY,
|
||||
FADE_OUT_CURVE_KEY,
|
||||
'fx',
|
||||
BUS_KEY,
|
||||
]);
|
||||
expect(cutOf(asset.props)).toEqual({
|
||||
expect(cutOf({})).toEqual({
|
||||
start: 0,
|
||||
end: 0,
|
||||
fadeIn: 0.005,
|
||||
@@ -293,17 +293,23 @@ describe('the cut of a sound (#343)', () => {
|
||||
expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]);
|
||||
});
|
||||
|
||||
it('is what every way of playing the asset is given, its fades in milliseconds on the asset', () => {
|
||||
it('is what every way of playing a clip is given, its fades in milliseconds on the clip (#357)', () => {
|
||||
const f = playingScene();
|
||||
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, START_KEY, 0.5));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_IN_KEY, 25));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_KEY, 40));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_CURVE_KEY, 'equalPower'));
|
||||
expect(playedSoundOf({ asset: f.sound, clip: null })).toBe(FILE);
|
||||
const add = new AddClipCommand(f.sound.id, { [START_KEY]: 0.5, [FADE_IN_KEY]: 25 });
|
||||
f.stack.execute(add);
|
||||
f.stack.execute(
|
||||
new SetClipPropsCommand(f.sound.id, add.clipId, {
|
||||
[FADE_OUT_KEY]: 40,
|
||||
[FADE_OUT_CURVE_KEY]: 'equalPower',
|
||||
}),
|
||||
);
|
||||
const name = `${FILE}#0.5~0~0.025~0.04~linear~equalPower`;
|
||||
expect(playedSoundOf(f.project, f.sound)).toBe(name);
|
||||
expect(playedSoundById(f.project, f.sound.id)).toBe(name);
|
||||
expect(playedSoundById(f.project, add.clipId)).toBe(name);
|
||||
// The sound itself stays the whole file.
|
||||
expect(playedSoundById(f.project, f.sound.id)).toBe(FILE);
|
||||
expect(playedSoundById(f.project, '')).toBe('');
|
||||
f.play.params.sound = add.clipId;
|
||||
const audio = fakeAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.played).toMatchObject([{ file: name }]);
|
||||
@@ -333,12 +339,13 @@ describe('the cut of a sound (#343)', () => {
|
||||
expect(Array.from(ramp)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
|
||||
const f = playingScene();
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, true));
|
||||
const add = new AddClipCommand(f.sound.id, { [REVERSE_KEY]: true });
|
||||
f.stack.execute(add);
|
||||
const name = `${FILE}#0~0~0.005~0.005~linear~linear~reverse`;
|
||||
expect(playedSoundOf(f.project, f.sound)).toBe(name);
|
||||
expect(playedSoundById(f.project, add.clipId)).toBe(name);
|
||||
expect(parseCutName(name).cut).toMatchObject({ start: 0, end: 0, reverse: true });
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, false));
|
||||
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
|
||||
f.stack.execute(new SetClipPropsCommand(f.sound.id, add.clipId, { [REVERSE_KEY]: false }));
|
||||
expect(playedSoundById(f.project, add.clipId)).toBe(FILE);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user