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
+16
-1
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user