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
+131
-57
@@ -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
|
||||
|
||||
Reference in new issue
Block a user