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

+16 -1
View File
@@ -308,11 +308,26 @@ export interface FontAsset extends AssetBase {
/**
* `file` names the sound in `data/sounds/` the way a font names its typeface: content-addressed
* `{folder}/{sha256}.{ext}`, empty when the upload failed. Nothing wears a sound – a graph plays it with
* `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`).
* `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`). The
* sound itself is the whole file; its clips are stretches of it (#357).
*/
export interface SoundAsset extends AssetBase {
type: 'sound';
file: string;
/** Absent for a sound without clips. */
clips?: SoundClip[];
}
/**
* A stretch of a sound's file with a name (#357), as a frame is a cut-out of a texture. `id` shares
* the generator with asset ids, so a sound field holds either. `props` are the cut's: start, end,
* fades, their curves and reverse (see `properties/sounds.ts`); volume, loop, bus and FX stay the
* sound's.
*/
export interface SoundClip {
id: string;
name: string;
props: PropertyBag;
}
/**
+80 -3
View File
@@ -1,3 +1,6 @@
import { nextFreeName, nextNumberedName } from '../commands/naming';
import { newId } from '../ids';
import type { Asset, SoundAsset, SoundClip } from './Asset';
import { findAsset, type Project } from './Project';
import { storedFilePattern } from './storedFile';
@@ -7,11 +10,85 @@ export function isSoundFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
export const CLIP_SEPARATOR = ' / ';
export const DEFAULT_CLIP_NAME = 'Clip';
export function clipsOf(asset: SoundAsset): readonly SoundClip[] {
return asset.clips ?? [];
}
/** What a sound field names (#357): a sound, the whole file, or one of its clips. */
export interface SoundRef {
asset: SoundAsset;
clip: SoundClip | null;
}
/**
* Clip ids share the generator with asset ids and are unique across the document, which lets a
* sound field hold either – as a texture field holds a texture or one of its frames.
*/
export function findSound(project: Project, id: string): SoundRef | null {
if (id === '') return null;
const asset = findAsset(project, id);
if (asset) return asset.type === 'sound' ? { asset, clip: null } : null;
for (const sound of project.assets) {
if (sound.type !== 'sound') continue;
const clip = clipsOf(sound).find((c) => c.id === id);
if (clip) return { asset: sound, clip };
}
return null;
}
/** The asset among `assets` an id names: the asset itself, or the sound one of whose clips it is. */
export function assetNamedBy(assets: readonly Asset[], id: string): Asset | undefined {
if (id === '') return undefined;
return assets.find((a) => a.id === id || (a.type === 'sound' && clipsOf(a).some((c) => c.id === id)));
}
/**
* Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes
* each old id with its new one in `ids` so the places that named a clip can follow.
*/
export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
if (copy.type !== 'sound') return;
for (const clip of clipsOf(copy)) {
const id = newId();
ids.set(clip.id, id);
clip.id = id;
}
}
/** Every id that means "this sound": the sound itself and each of its clips. */
export function soundRefIds(asset: SoundAsset): string[] {
return [asset.id, ...clipsOf(asset).map((c) => c.id)];
}
/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */
export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string {
return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName;
}
/** "Clip 1", 2, 3 …: the first number none of the sound's clips has. */
export function nextClipName(asset: SoundAsset): string {
return nextNumberedName(
clipsOf(asset).map((c) => c.name),
DEFAULT_CLIP_NAME,
);
}
/** A name free among the sound's clips: `name`, else "name 2", "name 3" … */
export function uniqueClipName(asset: SoundAsset, name: string, ignoreId = ''): string {
return nextFreeName(
clipsOf(asset)
.filter((c) => c.id !== ignoreId)
.map((c) => c.name),
name,
);
}
/** The file a `sound` value points at, empty when the id is unset or no longer resolves. */
export function soundFileOf(project: Project, id: string): string {
if (id === '') return '';
const asset = findAsset(project, id);
return asset?.type === 'sound' ? asset.file : '';
return findSound(project, id)?.asset.file ?? '';
}
/** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */