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:
adminandClaude Opus 5.5 committed 2026-10-08 20:18:47 +02:00
1 parent c7fa1d15b0
commit 42a3759d9d
32 files changed
+1230 -363

No files matched your search

+10 -3
View File
@@ -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) {
+143
View File
@@ -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;
}
}