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
@@ -18,7 +18,7 @@ import { heldSongGraph } from '../music/routing';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { isSoundFile, renewClipIds } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs';
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
@@ -192,7 +192,13 @@ export class DuplicateAssetCommand implements Command {
|
||||
|
||||
/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */
|
||||
function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] {
|
||||
const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()]));
|
||||
const ids = new Map<string, string>();
|
||||
const copies = embeddedIn(project, from).map((a) => {
|
||||
const copy = { ...structuredClone(a), id: newId(), owner: song.id };
|
||||
ids.set(a.id, copy.id);
|
||||
renewClipIds(copy, ids);
|
||||
return copy;
|
||||
});
|
||||
const renamed = (id: string) => ids.get(id) ?? id;
|
||||
for (const track of song.tracks) track.fx = renamed(track.fx);
|
||||
for (const item of song.items) {
|
||||
@@ -201,7 +207,7 @@ function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[]
|
||||
}
|
||||
const fx = song.props[ASSET_FX_KEY];
|
||||
if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx);
|
||||
return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id }));
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** A copy is a project asset of its own, even one of something a song kept to itself. */
|
||||
@@ -210,6 +216,7 @@ function copyOf(original: Asset, id: string, name: string): Asset {
|
||||
delete copy.owner;
|
||||
if (copy.type === 'texture' || copy.type === 'pixelart')
|
||||
for (const frame of copy.frames) frame.id = newId();
|
||||
renewClipIds(copy, new Map());
|
||||
if (copy.type === 'scene' || copy.type === 'container') {
|
||||
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
|
||||
for (const object of copy.objects) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/** The clips of a sound (#357): stretches of its file with a name, as frames are of a texture. */
|
||||
import { newId } from '../ids';
|
||||
import type { SoundAsset, SoundClip } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { clipsOf, nextClipName, uniqueClipName } from '../model/sound';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
function requireSound(project: Project, assetId: string): SoundAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'sound') throw new CommandError(`${asset.name} is not a sound`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function requireClip(asset: SoundAsset, clipId: string): SoundClip {
|
||||
const clip = clipsOf(asset).find((c) => c.id === clipId);
|
||||
if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** What a clip may hold: a row of `CLIP_ROWS`, a finite number at least 0, a curve it offers. */
|
||||
function heldClipValue(key: string, value: PropertyValue): PropertyValue {
|
||||
const def = CLIP_ROWS.find((row) => row.key === key);
|
||||
if (!def) throw new CommandError(`A clip has no "${key}"`);
|
||||
if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
|
||||
throw new CommandError(`${def.label} needs a number of at least 0`);
|
||||
if (def.kind === 'boolean' && typeof value !== 'boolean')
|
||||
throw new CommandError(`${def.label} is on or off`);
|
||||
if (def.kind === 'enum' && !def.options.some((option) => option.value === value))
|
||||
throw new CommandError(`${def.label} has no ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `clipId` is known before execution, so the caller can select the new clip right after executing. */
|
||||
export class AddClipCommand implements Command {
|
||||
readonly label = 'Add clip';
|
||||
readonly clipId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly props: PropertyBag,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const props: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(key, value);
|
||||
// Named per execution: a redo has to fit the names that exist when it runs.
|
||||
asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some of a clip's values in one step – a drag sets its start and its end together. The values to
|
||||
* revert to are read at execution time.
|
||||
*/
|
||||
export class SetClipPropsCommand implements Command {
|
||||
private previous: [string, PropertyValue | undefined][] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly values: PropertyBag,
|
||||
readonly label = 'Set clip',
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
const held = Object.entries(this.values).map(([key, value]) => [key, heldClipValue(key, value)] as const);
|
||||
this.previous = held.map(([key]) => [key, clip.props[key]]);
|
||||
for (const [key, value] of held) clip.props[key] = value;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
for (const [key, value] of this.previous)
|
||||
if (value === undefined) delete clip.props[key];
|
||||
else clip.props[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameClipCommand implements Command {
|
||||
readonly label = 'Rename clip';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clip = requireClip(asset, this.clipId);
|
||||
const name = this.name.trim();
|
||||
if (!name) throw new CommandError('A clip needs a name');
|
||||
this.previous = clip.name;
|
||||
clip.name = uniqueClipName(asset, name, this.clipId);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireClip(requireSound(project, this.assetId), this.clipId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo puts it back where it was. What plays it is left pointing at it, as a sprite is at a deleted
|
||||
* frame: the reference reads as missing and comes back by itself on undo.
|
||||
*/
|
||||
export class RemoveClipCommand implements Command {
|
||||
readonly label = 'Delete clip';
|
||||
private removed?: Removed<SoundClip>;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
const removed = removeFirst(clips, (c) => c.id === this.clipId);
|
||||
if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`);
|
||||
this.removed = removed;
|
||||
asset.clips = clips;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
restoreAt(clips, [this.removed]);
|
||||
asset.clips = clips;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user