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

+2 -44
View File
@@ -103,20 +103,7 @@ import {
} from './particles';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { FADE_CURVES } from '../model/sound';
import {
DEFAULT_FADE_CURVE,
DEFAULT_FADE_MS,
END_KEY as SOUND_END_KEY,
FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY,
FADE_IN_KEY as SOUND_FADE_IN_KEY,
FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY,
FADE_OUT_KEY as SOUND_FADE_OUT_KEY,
LOOP_KEY as SOUND_LOOP_KEY,
REVERSE_KEY as SOUND_REVERSE_KEY,
START_KEY as SOUND_START_KEY,
VOLUME_KEY as SOUND_VOLUME_KEY,
} from './sounds';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import {
BEAT_OPTIONS,
DEFAULT_BARS,
@@ -717,39 +704,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
font: [filterRow(true)],
// The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and
// a `Play sound` node scales the one and can add to the other.
// The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`).
sound: [
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
// That stretch backwards (#321), its fade in at the end it starts from.
{ key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
// The ramps at a start and an end inside the file, each with its shape (#345).
...(
[
[SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'],
[SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'],
] as const
).flatMap(([key, label, curveKey, curveLabel]) => [
{
key,
label,
kind: 'number' as const,
default: DEFAULT_FADE_MS,
min: 0,
max: 1000,
step: 1,
decimals: 0,
},
{
key: curveKey,
label: curveLabel,
kind: 'enum' as const,
default: DEFAULT_FADE_CURVE,
options: FADE_CURVES,
},
]),
FX_ROW,
BUS_ROW,
],
+22 -2
View File
@@ -1,6 +1,7 @@
import type { Asset } from '../model/Asset';
import type { Asset, SoundAsset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
import { clipsOf, findSound, soundRefName } from '../model/sound';
import { listedAssets, markedName } from '../music/embedded';
import { NO_FX } from './assetFx';
import { MASTER_BUS } from './buses';
@@ -17,7 +18,26 @@ export function assetRefLabel(project: Project | null, id: string): string {
if (id === '' || id === NO_FX) return NO_ASSET_LABEL;
if (id === MASTER_BUS) return MASTER_LABEL;
const asset = project ? findAsset(project, id) : undefined;
return asset ? markedName(asset) : `${id}?`;
if (asset) return markedName(asset);
// A clip of a sound (#357) reads "Sound / Clip", as a frame does.
const clip = project ? findSound(project, id) : null;
return clip ? soundRefName(clip, markedName(clip.asset)) : `${id}?`;
}
/** What a sound field offers (#357): each sound, followed by its clips. */
export function soundChoices(project: Project): { id: string; label: string }[] {
return assetRefChoices(project, ['sound']).flatMap((asset) =>
asset.type === 'sound' ? soundEntries(asset) : [],
);
}
/** A sound and its clips as choices, "Kick", "Kick / Clip 1" … */
export function soundEntries(asset: SoundAsset): { id: string; label: string }[] {
const name = markedName(asset);
return [
{ id: asset.id, label: name },
...clipsOf(asset).map((clip) => ({ id: clip.id, label: soundRefName({ asset, clip }, name) })),
];
}
/**
+10 -11
View File
@@ -1,19 +1,18 @@
import type { SoundAsset } from '../model/Asset';
import { findAssetOf, type Project } from '../model/Project';
import { cutName } from '../model/sound';
import { resolveAssetProps } from './resolve';
import type { Project } from '../model/Project';
import { cutName, findSound, type SoundRef } from '../model/sound';
import { cutOf } from './sounds';
/**
* What a sound asset is played by (#343): its file, or the stretch of it the asset is cut to. Every
* way a sound is heard asks this rather than for the file – the cut is the sample.
* What a sound is played by: the whole file for the sound itself, the stretch of it a clip is cut to
* for one of its clips (#357). Every way a sound is heard asks this rather than for the file – the
* cut is the sample.
*/
export function playedSoundOf(project: Project, asset: SoundAsset): string {
return cutName(asset.file, cutOf(resolveAssetProps(project, asset)));
export function playedSoundOf(ref: SoundRef): string {
return ref.clip ? cutName(ref.asset.file, cutOf(ref.clip.props)) : ref.asset.file;
}
/** The same by id, empty when the id is unset or no longer names a sound. */
/** The same by id, empty when the id is unset or names neither a sound nor a clip of one. */
export function playedSoundById(project: Project, id: string): string {
const asset = id === '' ? undefined : findAssetOf(project, id, 'sound');
return asset ? playedSoundOf(project, asset) : '';
const ref = findSound(project, id);
return ref ? playedSoundOf(ref) : '';
}
+5 -2
View File
@@ -1,20 +1,23 @@
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
import { findPixelFrame } from '../model/pixelart';
import { findAsset, type Project } from '../model/Project';
import { findSound } from '../model/sound';
import { findFrame } from '../model/texture';
import { isKindWith } from './kinds';
import type { PropertyDef } from './PropertySchema';
/**
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
* names the texture or pixel art it was cut from – that is the asset one can open (#221).
* names the texture or pixel art it was cut from – that is the asset one can open (#221) – and a
* clip its sound.
*/
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
if (!isKindWith('reference', def.kind)) return null;
const id = textOf(value);
if (id === '') return null;
if (findAsset(project, id)) return id;
if (def.kind !== 'texture') return null;
// A clip (#357) names the sound it was cut from, as a frame does.
if (def.kind !== 'texture') return findSound(project, id)?.asset.id ?? null;
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
}
+22 -3
View File
@@ -1,12 +1,13 @@
import { booleanOf, type PropertyBag } from '../model/Properties';
import { type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
import { FADE_CURVES, type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
import { coerceAs } from './kinds';
import type { PropertyDef } from './PropertySchema';
/** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */
export const VOLUME_KEY = 'volume';
/** Whether this sound is music: it repeats until something stops it. */
export const LOOP_KEY = 'loop';
/** Where in its file the sound starts and ends, in seconds; an end of 0 is the end of the file (#343). */
/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */
export const START_KEY = 'start';
export const END_KEY = 'end';
/** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */
@@ -20,6 +21,24 @@ export const DEFAULT_FADE_CURVE: FadeCurve = 'linear';
/** Whether the stretch plays backwards (#321). */
export const REVERSE_KEY = 'reverse';
/** The rows of a clip of a sound (#357): the stretch it is, set in the Sound tab, and how it is played. */
export const CLIP_ROWS: readonly PropertyDef[] = [
{ key: START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
{ key: END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
// That stretch backwards (#321), its fade in at the end it starts from.
{ key: REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
// The ramps at a start and an end inside the file, each with its shape (#345).
...(
[
[FADE_IN_KEY, 'Fade in (ms)', FADE_IN_CURVE_KEY, 'Fade in curve'],
[FADE_OUT_KEY, 'Fade out (ms)', FADE_OUT_CURVE_KEY, 'Fade out curve'],
] as const
).flatMap(([key, label, curveKey, curveLabel]): PropertyDef[] => [
{ key, label, kind: 'number', default: DEFAULT_FADE_MS, min: 0, max: 1000, step: 1, decimals: 0 },
{ key: curveKey, label: curveLabel, kind: 'enum', default: DEFAULT_FADE_CURVE, options: FADE_CURVES },
]),
];
export function volumeOf(bag: PropertyBag): number {
return coerceAs(bag[VOLUME_KEY], 'number', 1);
}
@@ -28,7 +47,7 @@ export function loopsOf(bag: PropertyBag): boolean {
return booleanOf(bag[LOOP_KEY]);
}
/** The stretch of its file a sound is, its fades in seconds. */
/** The stretch of its file a clip is, its fades in seconds. */
export function cutOf(bag: PropertyBag): SoundCut {
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
const curve = (key: string) => {