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
@@ -18,7 +18,7 @@ import { heldSongGraph } from '../music/routing';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { isSoundFile, renewClipIds } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs';
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
@@ -192,7 +192,13 @@ export class DuplicateAssetCommand implements Command {
|
||||
|
||||
/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */
|
||||
function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] {
|
||||
const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()]));
|
||||
const ids = new Map<string, string>();
|
||||
const copies = embeddedIn(project, from).map((a) => {
|
||||
const copy = { ...structuredClone(a), id: newId(), owner: song.id };
|
||||
ids.set(a.id, copy.id);
|
||||
renewClipIds(copy, ids);
|
||||
return copy;
|
||||
});
|
||||
const renamed = (id: string) => ids.get(id) ?? id;
|
||||
for (const track of song.tracks) track.fx = renamed(track.fx);
|
||||
for (const item of song.items) {
|
||||
@@ -201,7 +207,7 @@ function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[]
|
||||
}
|
||||
const fx = song.props[ASSET_FX_KEY];
|
||||
if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx);
|
||||
return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id }));
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** A copy is a project asset of its own, even one of something a song kept to itself. */
|
||||
@@ -210,6 +216,7 @@ function copyOf(original: Asset, id: string, name: string): Asset {
|
||||
delete copy.owner;
|
||||
if (copy.type === 'texture' || copy.type === 'pixelart')
|
||||
for (const frame of copy.frames) frame.id = newId();
|
||||
renewClipIds(copy, new Map());
|
||||
if (copy.type === 'scene' || copy.type === 'container') {
|
||||
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
|
||||
for (const object of copy.objects) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/** The clips of a sound (#357): stretches of its file with a name, as frames are of a texture. */
|
||||
import { newId } from '../ids';
|
||||
import type { SoundAsset, SoundClip } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { clipsOf, nextClipName, uniqueClipName } from '../model/sound';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
function requireSound(project: Project, assetId: string): SoundAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'sound') throw new CommandError(`${asset.name} is not a sound`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function requireClip(asset: SoundAsset, clipId: string): SoundClip {
|
||||
const clip = clipsOf(asset).find((c) => c.id === clipId);
|
||||
if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** What a clip may hold: a row of `CLIP_ROWS`, a finite number at least 0, a curve it offers. */
|
||||
function heldClipValue(key: string, value: PropertyValue): PropertyValue {
|
||||
const def = CLIP_ROWS.find((row) => row.key === key);
|
||||
if (!def) throw new CommandError(`A clip has no "${key}"`);
|
||||
if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
|
||||
throw new CommandError(`${def.label} needs a number of at least 0`);
|
||||
if (def.kind === 'boolean' && typeof value !== 'boolean')
|
||||
throw new CommandError(`${def.label} is on or off`);
|
||||
if (def.kind === 'enum' && !def.options.some((option) => option.value === value))
|
||||
throw new CommandError(`${def.label} has no ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `clipId` is known before execution, so the caller can select the new clip right after executing. */
|
||||
export class AddClipCommand implements Command {
|
||||
readonly label = 'Add clip';
|
||||
readonly clipId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly props: PropertyBag,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const props: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(key, value);
|
||||
// Named per execution: a redo has to fit the names that exist when it runs.
|
||||
asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some of a clip's values in one step – a drag sets its start and its end together. The values to
|
||||
* revert to are read at execution time.
|
||||
*/
|
||||
export class SetClipPropsCommand implements Command {
|
||||
private previous: [string, PropertyValue | undefined][] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly values: PropertyBag,
|
||||
readonly label = 'Set clip',
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
const held = Object.entries(this.values).map(([key, value]) => [key, heldClipValue(key, value)] as const);
|
||||
this.previous = held.map(([key]) => [key, clip.props[key]]);
|
||||
for (const [key, value] of held) clip.props[key] = value;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
for (const [key, value] of this.previous)
|
||||
if (value === undefined) delete clip.props[key];
|
||||
else clip.props[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameClipCommand implements Command {
|
||||
readonly label = 'Rename clip';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clip = requireClip(asset, this.clipId);
|
||||
const name = this.name.trim();
|
||||
if (!name) throw new CommandError('A clip needs a name');
|
||||
this.previous = clip.name;
|
||||
clip.name = uniqueClipName(asset, name, this.clipId);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireClip(requireSound(project, this.assetId), this.clipId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo puts it back where it was. What plays it is left pointing at it, as a sprite is at a deleted
|
||||
* frame: the reference reads as missing and comes back by itself on undo.
|
||||
*/
|
||||
export class RemoveClipCommand implements Command {
|
||||
readonly label = 'Delete clip';
|
||||
private removed?: Removed<SoundClip>;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
const removed = removeFirst(clips, (c) => c.id === this.clipId);
|
||||
if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`);
|
||||
this.removed = removed;
|
||||
asset.clips = clips;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
restoreAt(clips, [this.removed]);
|
||||
asset.clips = clips;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { InstrumentAsset } from '../model/Asset';
|
||||
import { graphLookup } from '../model/graphLookup';
|
||||
import { textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { playedSoundOf } from '../properties/playedSound';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { volumeOf } from '../properties/sounds';
|
||||
@@ -77,19 +78,18 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
|
||||
return {
|
||||
lookup: graphLookup(project),
|
||||
sound: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'sound');
|
||||
if (!asset) return null;
|
||||
const ref = findSound(project, assetId);
|
||||
if (!ref) return null;
|
||||
// An asset without a file stays without one, so the Sample can say so.
|
||||
const file = asset.file ? playedSoundOf(project, asset) : '';
|
||||
return { file, volume: volumeOf(resolveAssetProps(project, asset)) };
|
||||
return { file: playedSoundOf(ref), volume: volumeOf(resolveAssetProps(project, ref.asset)) };
|
||||
},
|
||||
soundFx: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'soundfx');
|
||||
return asset ? soundFxBuildFor(asset, project).compiled : null;
|
||||
},
|
||||
key: (assetId) => {
|
||||
const sound = findAssetOf(project, assetId, 'sound');
|
||||
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
|
||||
const sound = findSound(project, assetId);
|
||||
if (sound) return `${playedSoundOf(sound)}@${volumeOf(resolveAssetProps(project, sound.asset))}`;
|
||||
const fx = findAssetOf(project, assetId, 'soundfx');
|
||||
return fx ? soundFxKey(fx, project) : '';
|
||||
},
|
||||
|
||||
+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. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Asset, PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { assetNamedBy, renewClipIds } from '../model/sound';
|
||||
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
|
||||
import { nextNumberedName } from '../commands/naming';
|
||||
import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
|
||||
@@ -91,9 +92,15 @@ function ownedBy<T extends Asset>(asset: T, song: SongAsset): T {
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. */
|
||||
export function embeddedCopy(asset: Asset, song: SongAsset): Asset {
|
||||
return ownedBy({ ...structuredClone(asset), id: newId() }, song);
|
||||
/**
|
||||
* A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest.
|
||||
* `ids` says what the asset and its clips (#357) are called in the copy.
|
||||
*/
|
||||
export function embeddedCopy(asset: Asset, song: SongAsset): { copy: Asset; ids: Map<string, string> } {
|
||||
const copy = ownedBy({ ...structuredClone(asset), id: newId() }, song);
|
||||
const ids = new Map([[asset.id, copy.id]]);
|
||||
renewClipIds(copy, ids);
|
||||
return { copy, ids };
|
||||
}
|
||||
|
||||
/** The patterns that follow `key` of an asset: those a song keeps, when the key is part of its clock. */
|
||||
@@ -164,8 +171,10 @@ function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem
|
||||
|
||||
/** The embedded assets the places of these items name, wherever they belong: what a copy of them takes along. */
|
||||
export function embeddedUsedBy(project: Project, items: readonly SongItem[]): Asset[] {
|
||||
const ids = new Set(items.flatMap((i) => [i.asset, i.fx]));
|
||||
return project.assets.filter((a) => isEmbedded(a) && ids.has(a.id));
|
||||
const named = new Set(
|
||||
items.flatMap((i) => [i.asset, i.fx]).map((id) => assetNamedBy(project.assets, id)?.id),
|
||||
);
|
||||
return project.assets.filter((a) => isEmbedded(a) && named.has(a.id));
|
||||
}
|
||||
|
||||
export interface Settled {
|
||||
@@ -194,20 +203,24 @@ export function settleEmbedded(
|
||||
const own = { ...song.props };
|
||||
slotsOf(own, tracks, items).forEach((slot, index) => {
|
||||
if (slot.id === '' || slot.id === NO_FX) return;
|
||||
const found = findAsset(project, slot.id) ?? added.find((a) => a.id === slot.id);
|
||||
const source = found ?? spare.find((a) => a.id === slot.id);
|
||||
// A place may name a clip of a sound (#357): the sound is what it claims.
|
||||
const found = assetNamedBy(project.assets, slot.id) ?? assetNamedBy(added, slot.id);
|
||||
const source = found ?? assetNamedBy(spare, slot.id);
|
||||
if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return;
|
||||
if (found?.owner === song.id && !claimed.has(found.id)) {
|
||||
claimed.add(found.id);
|
||||
return;
|
||||
}
|
||||
if (index === 0) return;
|
||||
const id = found || taken.has(source.id) ? newId() : source.id;
|
||||
const fresh = found !== undefined || taken.has(source.id);
|
||||
const id = fresh ? newId() : source.id;
|
||||
const copy = ownedBy({ ...structuredClone(source), id }, song);
|
||||
const ids = new Map([[source.id, id]]);
|
||||
if (fresh) renewClipIds(copy, ids);
|
||||
taken.add(id);
|
||||
added.push(copy);
|
||||
claimed.add(id);
|
||||
slot.set(id);
|
||||
slot.set(ids.get(slot.id) ?? id);
|
||||
});
|
||||
const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id);
|
||||
return { added, dropped };
|
||||
|
||||
+12
-5
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { pickedFx } from '../properties/assetFx';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
@@ -148,8 +149,14 @@ export function dbToGain(db: number): number {
|
||||
|
||||
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
|
||||
export function itemFx(project: Project, item: SongItem): string {
|
||||
const played = findAsset(project, item.asset);
|
||||
return pickedFx(item.fx, played?.type === item.kind ? played.props : {});
|
||||
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
|
||||
}
|
||||
|
||||
/** The asset an item plays: its pattern, or its sound – also when it plays a clip of it (#357). */
|
||||
export function itemAsset(project: Project, item: SongItem): PatternAsset | SoundAsset | undefined {
|
||||
return item.kind === 'sound'
|
||||
? findSound(project, item.asset)?.asset
|
||||
: findAssetOf(project, item.asset, 'pattern');
|
||||
}
|
||||
|
||||
/** Whether a pattern item is heard on a bus of its own (#346): it goes through a sound FX that is there. */
|
||||
@@ -207,7 +214,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||
const volume = dbToGain(item.volume);
|
||||
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track);
|
||||
if (item.kind === 'sound') {
|
||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||
if (!findSound(project, item.asset)?.asset.file) continue;
|
||||
clips.push({
|
||||
tick: item.tick,
|
||||
length: item.length,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
PixelFrame,
|
||||
PixelLayer,
|
||||
SceneAsset,
|
||||
SoundClip,
|
||||
StateMachineAsset,
|
||||
TextureFrame,
|
||||
GradientStop,
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||
|
||||
/**
|
||||
@@ -258,7 +260,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props, file });
|
||||
} else if (type === 'sound') {
|
||||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||||
assets.push({ id, type, name, props, file });
|
||||
const clips = normalizeClips(a.clips, takenIds);
|
||||
assets.push({ id, type, name, props, file, ...(clips.length > 0 && { clips }) });
|
||||
} else if (type === 'tile') {
|
||||
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
|
||||
// in the tab and comes back when the texture it named is restored by an undo.
|
||||
@@ -738,6 +741,23 @@ function normalizeTexture(
|
||||
return { file, width, height, frames };
|
||||
}
|
||||
|
||||
/** The clips of a sound (#357); like a frame's, a clip's id may not repeat one the document has. */
|
||||
function normalizeClips(raw: unknown, takenIds: Set<string>): SoundClip[] {
|
||||
const clips: SoundClip[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const c = asRecord(item);
|
||||
const id = typeof c.id === 'string' ? c.id : '';
|
||||
if (!id || takenIds.has(id)) continue;
|
||||
takenIds.add(id);
|
||||
clips.push({
|
||||
id,
|
||||
name: typeof c.name === 'string' ? c.name : '',
|
||||
props: normalizeBag(c.props, CLIP_ROWS, true),
|
||||
});
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`.
|
||||
* A picture without them is one nobody sliced, and keeps saying so.
|
||||
|
||||
Reference in new issue
Block a user