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

+131 -57
View File
@@ -1912,25 +1912,36 @@ export async function sound(t) {
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave between two handles. Dragging the end
// handle cuts the sound there, a drag beside the handles spans a new stretch, and that
// stretch is what plays: a name of its own, its samples cut to the sample and ramped from
// silence at an edge inside the file. One drag is one undo step.
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its
// clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a
// click picks one, and the picked clip stands between two handles. A clip is played by a
// name of its own, its samples cut to the sample and ramped from silence at an edge inside
// the file. One drag is one undo step.
const droneId = both.find((a) => a.file === drone.file)?.id;
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const tab = () => json('__gm.soundTab.span()');
const clips = () => json('__gm.soundTab.clips()');
const picked = () => json('__gm.soundTab.pickedClip()');
await waitFor(tab, (span) => span !== null);
const tabOpened = {
current: await json('__gm.soundTab.current()'),
span: await tab(),
info: await json('__gm.soundTab.info()'),
clips: await clips(),
picked: await picked(),
reverse: await json('__gm.soundTab.reverseButton()'),
played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`),
};
check(
'opening a sound shows its tab: the whole file between the handles',
'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade',
tabOpened.current === droneId &&
tabOpened.span?.start === 0 &&
Math.abs(tabOpened.span?.end - 0.75) < 0.001 &&
tabOpened.info === '0.75 s',
tabOpened.info === '0.75 s' &&
tabOpened.clips.length === 0 &&
tabOpened.picked === null &&
tabOpened.reverse.enabled === false &&
tabOpened.played === drone.file,
JSON.stringify(tabOpened),
);
// 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room
@@ -1969,51 +1980,74 @@ export async function sound(t) {
Math.abs(refitted.top - fullTop) < 0.5,
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
);
const droneProps = async () => (await assetsOfType('sound')).find((a) => a.id === droneId)?.props;
let dragFrom = await json('__gm.soundTab.at(0.75)');
let dragTo = await json('__gm.soundTab.at(0.5)');
const clipProps = async () => (await clips())[0]?.props;
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
let dragTo = await json('__gm.soundTab.stripAt(0.3)');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') };
const clipId = spanned.clips[0]?.id;
check(
'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file',
spanned.clips.length === 1 &&
spanned.clips[0].name === 'Clip 1' &&
Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 &&
Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 &&
spanned.picked === clipId &&
spanned.info ===
`${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` &&
(await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file,
JSON.stringify(spanned),
);
await press('z', { ctrl: true });
const undoneClip = { clips: await clips(), picked: await picked() };
await press('z', { ctrl: true, shift: true });
const redoneClip = await clips();
const beside = await json('__gm.soundTab.stripAt(0.5)');
await click(beside.x, beside.y);
const pickedBeside = await picked();
const onBand = await json('__gm.soundTab.stripAt(0.2)');
await click(onBand.x, onBand.y);
check(
'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound',
undoneClip.clips.length === 0 &&
undoneClip.picked === null &&
redoneClip.map((c) => c.id).join() === clipId &&
pickedBeside === null &&
(await picked()) === clipId,
JSON.stringify({ undoneClip, redoneClip, pickedBeside, picked: await picked() }),
);
dragFrom = await json('__gm.soundTab.at(0.3)');
dragTo = await json('__gm.soundTab.at(0.5)');
await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y);
await t.mouse('mouseMoved', dragFrom.x, dragFrom.y);
const onHandle = await json('__gm.soundTab.cursor()');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const shortened = { props: await droneProps(), info: await json('__gm.soundTab.info()') };
check(
'dragging the end handle cuts the sound there, the handle showing it can be dragged',
onHandle === 'ew-resize' &&
shortened.props?.start === 0 &&
Math.abs(shortened.props?.end - 0.5) < 0.01 &&
shortened.info === `${shortened.props?.end.toFixed(2)} s of 0.75 s`,
JSON.stringify({ onHandle, ...shortened }),
);
const lengthened = await clipProps();
await press('z', { ctrl: true });
const undoneCut = await droneProps();
await press('z', { ctrl: true, shift: true });
check(
'one undo puts the whole file back, a redo the cut',
(undoneCut?.end ?? 0) === 0 && Math.abs((await droneProps())?.end - 0.5) < 0.01,
JSON.stringify({ undoneCut, redone: await droneProps() }),
'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged',
onHandle === 'ew-resize' &&
Math.abs(lengthened?.start - 0.1) < 0.01 &&
Math.abs(lengthened?.end - 0.5) < 0.01 &&
Math.abs((await clipProps())?.end - 0.3) < 0.01,
JSON.stringify({ onHandle, lengthened, undone: await clipProps() }),
);
dragFrom = await json('__gm.soundTab.at(0.1)');
dragTo = await json('__gm.soundTab.at(0.3)');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const spanned = await droneProps();
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const clipCut = await clipProps();
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`);
const keptSamples = spanned ? Math.round((spanned.end - spanned.start) * (cutSamples?.rate ?? 0)) : -1;
const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1;
check(
'a drag beside the handles spans a new stretch, played by a name of its own, cut to the sample',
Math.abs(spanned?.start - 0.1) < 0.01 &&
Math.abs(spanned?.end - 0.3) < 0.01 &&
cutPlayed === `${drone.file}#${spanned?.start}~${spanned?.end}~0.005~0.005~linear~linear` &&
'the clip is played by a name of its own, cut to the sample',
cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` &&
cutSamples?.length === keptSamples,
JSON.stringify({ spanned, cutPlayed, cutSamples, keptSamples }),
JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }),
);
check(
'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file',
cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01,
JSON.stringify(cutSamples),
);
// 34h-ii. Each edge has a fade of its own, each shaped by its own curve (#345).
// 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345).
const tabFadeOut = await json('__gm.soundTab.fadeOutField()');
await click(tabFadeOut.x, tabFadeOut.y);
await t.typeText('40');
@@ -2023,17 +2057,17 @@ export async function sound(t) {
await click(curveOutButton.x, curveOutButton.y);
await clickText('Exponential');
await sleep(150);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`);
check(
'the header sets the fade out and its curve on their own, the fade in staying Linear',
(await droneProps())?.fadeOut === 40 &&
(await droneProps())?.fadeOutCurve === 'exponential' &&
((await droneProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await clipProps())?.fadeOut === 40 &&
(await clipProps())?.fadeOutCurve === 'exponential' &&
((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' &&
(await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' &&
curved.endsWith('~0.005~0.04~linear~exponential'),
JSON.stringify({ props: await droneProps(), curved }),
JSON.stringify({ props: await clipProps(), curved }),
);
await press('z', { ctrl: true });
await press('z', { ctrl: true });
@@ -2046,14 +2080,14 @@ export async function sound(t) {
await t.typeText('0');
await press('Enter');
await sleep(150);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
check(
'after two undos the curve is Linear again, and both fades go to 0 in the header',
(await droneProps())?.fadeIn === 0 &&
(await droneProps())?.fadeOut === 0 &&
((await droneProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
(await clipProps())?.fadeIn === 0 &&
(await clipProps())?.fadeOut === 0 &&
((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
unfaded.endsWith('~0~0~linear~linear'),
JSON.stringify({ props: await droneProps(), unfaded }),
JSON.stringify({ props: await clipProps(), unfaded }),
);
// The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the
// exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp.
@@ -2083,7 +2117,7 @@ export async function sound(t) {
(on) => on === false,
);
check(
'Play plays only the stretch, its playhead running inside it, and stops when it ends',
'Play plays only the picked clip, its playhead running inside it, and stops when it ends',
tabHeard.last?.file === unfaded &&
Math.abs(tabHeard.last?.duration - 0.2) < 0.01 &&
tabPlayhead !== null &&
@@ -2093,25 +2127,32 @@ export async function sound(t) {
(await json('__gm.soundTab.playhead()')) === null,
JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }),
);
// 34h-iii. Reverse plays the same stretch backwards, the playhead running from its end (#321).
// 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321).
const reverseButton = await json('__gm.soundTab.reverseButton()');
await click(reverseButton.x, reverseButton.y);
await sleep(150);
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`);
check(
'Reverse turns the stretch around: its first samples are the last of the stretch played forwards',
(await droneProps())?.reverse === true &&
'Reverse turns the clip around: its first samples are the last of the clip played forwards',
(await clipProps())?.reverse === true &&
(await json('__gm.soundTab.reverseButton()')).on === true &&
reversed === `${unfaded}~reverse` &&
reversedSamples?.length === rawSamples?.length &&
reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)),
JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }),
);
// The whole file (0.75 s) gives the playhead time for several readings.
const tabWhole = await json('__gm.soundTab.wholeButton()');
await click(tabWhole.x, tabWhole.y);
await sleep(150);
// Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged
// past both ends of it.
for (const [from, to] of [
[0.1, -0.1],
[0.3, 0.9],
]) {
const a = await json(`__gm.soundTab.at(${from})`);
const b = await json(`__gm.soundTab.at(${to})`);
await t.drag(a.x, a.y, b.x, b.y);
}
const whole = await clipProps();
await click(tabPlay.x, tabPlay.y);
await waitFor(
() => json('__gm.soundTab.playhead()'),
@@ -2125,15 +2166,48 @@ export async function sound(t) {
)
).filter((seconds) => seconds !== null);
check(
'played backwards, the playhead runs from the end of the file towards its start',
(await droneProps())?.reverse === true &&
'played backwards, the playhead runs from the end of the clip towards its start',
whole?.reverse === true &&
whole?.start === 0 &&
whole?.end === 0 &&
backwards.length >= 2 &&
backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) &&
backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) &&
backwards.at(-1) < backwards[0],
JSON.stringify(backwards),
JSON.stringify({ whole, backwards }),
);
await shot('34-sound-tab');
// 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1".
await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' });
// The row is named after the port, as the node body names it.
const soundRow = await json("__gm.inspectorField('sound', true)");
await click(soundRow.x, soundRow.y);
const soundOffered = await json('__gm.overlayTexts()');
await clickText('drone / Clip 1');
await sleep(150);
const playNode = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find(
(n) => n.id === 'sn-play',
);
check(
'a sound field offers each clip after its sound, and picking one names the clip',
soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 &&
(await playNode())?.params.sound === clipId,
JSON.stringify({ soundOffered, node: await playNode() }),
);
// 34h-v. Delete takes the picked clip away, and the sound stays.
await evaluate(
`__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`,
);
await waitFor(picked, (id) => id === clipId);
await press('Delete');
check(
'Delete takes the picked clip away, the sound staying on screen',
(await clips()).length === 0 &&
(await picked()) === null &&
(await json('__gm.soundTab.current()')) === droneId,
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
);
// 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the