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
@@ -103,20 +103,7 @@ import {
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||||
import { FADE_CURVES } from '../model/sound';
|
||||
import {
|
||||
DEFAULT_FADE_CURVE,
|
||||
DEFAULT_FADE_MS,
|
||||
END_KEY as SOUND_END_KEY,
|
||||
FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY,
|
||||
FADE_IN_KEY as SOUND_FADE_IN_KEY,
|
||||
FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY,
|
||||
FADE_OUT_KEY as SOUND_FADE_OUT_KEY,
|
||||
LOOP_KEY as SOUND_LOOP_KEY,
|
||||
REVERSE_KEY as SOUND_REVERSE_KEY,
|
||||
START_KEY as SOUND_START_KEY,
|
||||
VOLUME_KEY as SOUND_VOLUME_KEY,
|
||||
} from './sounds';
|
||||
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
||||
import {
|
||||
BEAT_OPTIONS,
|
||||
DEFAULT_BARS,
|
||||
@@ -717,39 +704,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
font: [filterRow(true)],
|
||||
// The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and
|
||||
// a `Play sound` node scales the one and can add to the other.
|
||||
// The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`).
|
||||
sound: [
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
|
||||
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
// That stretch backwards (#321), its fade in at the end it starts from.
|
||||
{ key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
|
||||
// The ramps at a start and an end inside the file, each with its shape (#345).
|
||||
...(
|
||||
[
|
||||
[SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'],
|
||||
[SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'],
|
||||
] as const
|
||||
).flatMap(([key, label, curveKey, curveLabel]) => [
|
||||
{
|
||||
key,
|
||||
label,
|
||||
kind: 'number' as const,
|
||||
default: DEFAULT_FADE_MS,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: curveKey,
|
||||
label: curveLabel,
|
||||
kind: 'enum' as const,
|
||||
default: DEFAULT_FADE_CURVE,
|
||||
options: FADE_CURVES,
|
||||
},
|
||||
]),
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { Asset, SoundAsset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { clipsOf, findSound, soundRefName } from '../model/sound';
|
||||
import { listedAssets, markedName } from '../music/embedded';
|
||||
import { NO_FX } from './assetFx';
|
||||
import { MASTER_BUS } from './buses';
|
||||
@@ -17,7 +18,26 @@ export function assetRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '' || id === NO_FX) return NO_ASSET_LABEL;
|
||||
if (id === MASTER_BUS) return MASTER_LABEL;
|
||||
const asset = project ? findAsset(project, id) : undefined;
|
||||
return asset ? markedName(asset) : `${id}?`;
|
||||
if (asset) return markedName(asset);
|
||||
// A clip of a sound (#357) reads "Sound / Clip", as a frame does.
|
||||
const clip = project ? findSound(project, id) : null;
|
||||
return clip ? soundRefName(clip, markedName(clip.asset)) : `${id}?`;
|
||||
}
|
||||
|
||||
/** What a sound field offers (#357): each sound, followed by its clips. */
|
||||
export function soundChoices(project: Project): { id: string; label: string }[] {
|
||||
return assetRefChoices(project, ['sound']).flatMap((asset) =>
|
||||
asset.type === 'sound' ? soundEntries(asset) : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** A sound and its clips as choices, "Kick", "Kick / Clip 1" … */
|
||||
export function soundEntries(asset: SoundAsset): { id: string; label: string }[] {
|
||||
const name = markedName(asset);
|
||||
return [
|
||||
{ id: asset.id, label: name },
|
||||
...clipsOf(asset).map((clip) => ({ id: clip.id, label: soundRefName({ asset, clip }, name) })),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { SoundAsset } from '../model/Asset';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { cutName } from '../model/sound';
|
||||
import { resolveAssetProps } from './resolve';
|
||||
import type { Project } from '../model/Project';
|
||||
import { cutName, findSound, type SoundRef } from '../model/sound';
|
||||
import { cutOf } from './sounds';
|
||||
|
||||
/**
|
||||
* What a sound asset is played by (#343): its file, or the stretch of it the asset is cut to. Every
|
||||
* way a sound is heard asks this rather than for the file – the cut is the sample.
|
||||
* What a sound is played by: the whole file for the sound itself, the stretch of it a clip is cut to
|
||||
* for one of its clips (#357). Every way a sound is heard asks this rather than for the file – the
|
||||
* cut is the sample.
|
||||
*/
|
||||
export function playedSoundOf(project: Project, asset: SoundAsset): string {
|
||||
return cutName(asset.file, cutOf(resolveAssetProps(project, asset)));
|
||||
export function playedSoundOf(ref: SoundRef): string {
|
||||
return ref.clip ? cutName(ref.asset.file, cutOf(ref.clip.props)) : ref.asset.file;
|
||||
}
|
||||
|
||||
/** The same by id, empty when the id is unset or no longer names a sound. */
|
||||
/** The same by id, empty when the id is unset or names neither a sound nor a clip of one. */
|
||||
export function playedSoundById(project: Project, id: string): string {
|
||||
const asset = id === '' ? undefined : findAssetOf(project, id, 'sound');
|
||||
return asset ? playedSoundOf(project, asset) : '';
|
||||
const ref = findSound(project, id);
|
||||
return ref ? playedSoundOf(ref) : '';
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
|
||||
import { findPixelFrame } from '../model/pixelart';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { findFrame } from '../model/texture';
|
||||
import { isKindWith } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221).
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221) – and a
|
||||
* clip its sound.
|
||||
*/
|
||||
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
|
||||
if (!isKindWith('reference', def.kind)) return null;
|
||||
const id = textOf(value);
|
||||
if (id === '') return null;
|
||||
if (findAsset(project, id)) return id;
|
||||
if (def.kind !== 'texture') return null;
|
||||
// A clip (#357) names the sound it was cut from, as a frame does.
|
||||
if (def.kind !== 'texture') return findSound(project, id)?.asset.id ?? null;
|
||||
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { booleanOf, type PropertyBag } from '../model/Properties';
|
||||
import { type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
|
||||
import { FADE_CURVES, type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
|
||||
import { coerceAs } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */
|
||||
export const VOLUME_KEY = 'volume';
|
||||
/** Whether this sound is music: it repeats until something stops it. */
|
||||
export const LOOP_KEY = 'loop';
|
||||
/** Where in its file the sound starts and ends, in seconds; an end of 0 is the end of the file (#343). */
|
||||
/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */
|
||||
export const START_KEY = 'start';
|
||||
export const END_KEY = 'end';
|
||||
/** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */
|
||||
@@ -20,6 +21,24 @@ export const DEFAULT_FADE_CURVE: FadeCurve = 'linear';
|
||||
/** Whether the stretch plays backwards (#321). */
|
||||
export const REVERSE_KEY = 'reverse';
|
||||
|
||||
/** The rows of a clip of a sound (#357): the stretch it is, set in the Sound tab, and how it is played. */
|
||||
export const CLIP_ROWS: readonly PropertyDef[] = [
|
||||
{ key: START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
{ key: END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
// That stretch backwards (#321), its fade in at the end it starts from.
|
||||
{ key: REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
|
||||
// The ramps at a start and an end inside the file, each with its shape (#345).
|
||||
...(
|
||||
[
|
||||
[FADE_IN_KEY, 'Fade in (ms)', FADE_IN_CURVE_KEY, 'Fade in curve'],
|
||||
[FADE_OUT_KEY, 'Fade out (ms)', FADE_OUT_CURVE_KEY, 'Fade out curve'],
|
||||
] as const
|
||||
).flatMap(([key, label, curveKey, curveLabel]): PropertyDef[] => [
|
||||
{ key, label, kind: 'number', default: DEFAULT_FADE_MS, min: 0, max: 1000, step: 1, decimals: 0 },
|
||||
{ key: curveKey, label: curveLabel, kind: 'enum', default: DEFAULT_FADE_CURVE, options: FADE_CURVES },
|
||||
]),
|
||||
];
|
||||
|
||||
export function volumeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VOLUME_KEY], 'number', 1);
|
||||
}
|
||||
@@ -28,7 +47,7 @@ export function loopsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[LOOP_KEY]);
|
||||
}
|
||||
|
||||
/** The stretch of its file a sound is, its fades in seconds. */
|
||||
/** The stretch of its file a clip is, its fades in seconds. */
|
||||
export function cutOf(bag: PropertyBag): SoundCut {
|
||||
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
|
||||
const curve = (key: string) => {
|
||||
|
||||
Reference in new issue
Block a user