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

+14 -5
View File
@@ -1,4 +1,5 @@
import { findAssetOf } from '../../core/model/Project';
import { clipsOf, findSound } from '../../core/model/sound';
import { playedSoundOf } from '../../core/properties/playedSound';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { centreOf, type HookContext } from './hookHelpers';
@@ -25,11 +26,19 @@ export function soundHooks({ state, editor }: HookContext) {
playhead: () => soundEditor.canvas.playhead,
playing: () => soundEditor.isPlaying,
playButton: () => ({ ...centreOf(soundEditor.playButton), text: soundEditor.playButton.text }),
wholeButton: () => ({ ...centreOf(soundEditor.wholeButton), enabled: soundEditor.wholeButton.enabled }),
reverseButton: () => ({
...centreOf(soundEditor.reverseButton),
on: soundEditor.reverseButton.toggled,
enabled: soundEditor.reverseButton.enabled,
}),
/** The clips of the sound shown (#357): id, name and stored values; the picked one's id. */
clips: () => {
const asset = findAssetOf(state.project, soundEditor.currentAssetId, 'sound');
return asset ? clipsOf(asset).map((c) => ({ id: c.id, name: c.name, props: { ...c.props } })) : [];
},
pickedClip: () => soundEditor.canvas.pickedClip()?.id ?? null,
/** The middle of the clip strip at a second of the file, on screen. */
stripAt: (seconds: number) => soundEditor.canvas.stripAt(seconds),
fadeInField: () => ({ ...centreOf(soundEditor.fadeInField), text: soundEditor.fadeInField.value }),
fadeOutField: () => ({ ...centreOf(soundEditor.fadeOutField), text: soundEditor.fadeOutField.value }),
/** The curve dropdowns show a picture of their curve; the tooltip names it. */
@@ -41,10 +50,10 @@ export function soundHooks({ state, editor }: HookContext) {
...centreOf(soundEditor.curveOutButton),
tooltip: soundEditor.curveOutButton.tooltipText(),
}),
/** What a sound asset is played by: its file, or the name of its cut. */
played: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'sound');
return asset ? playedSoundOf(state.project, asset) : null;
/** What a sound or a clip of one is played by: its file, or the name of the clip's cut. */
played: (id: string) => {
const ref = findSound(state.project, id);
return ref ? playedSoundOf(ref) : null;
},
/** The samples a name plays, cut and faded: their count, rate, and the first and last few of channel 0. */
samples: async (name: string) => {