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

+10 -3
View File
@@ -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) {
+143
View File
@@ -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;
}
}
+6 -6
View File
@@ -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
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. */
+22 -9
View File
@@ -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
View File
@@ -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,
+2 -44
View File
@@ -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,
],
+22 -2
View File
@@ -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) })),
];
}
/**
+10 -11
View File
@@ -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) : '';
}
+5 -2
View File
@@ -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;
}
+22 -3
View File
@@ -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) => {
+21 -1
View File
@@ -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.
+2 -2
View File
@@ -527,12 +527,12 @@ export class Editor implements PlayHost {
const focus = ui().focus;
focus.tabBelongsElsewhere = () => this.tab === 'game';
this.cleanups.push(() => (focus.tabBelongsElsewhere = () => false));
// A node, a frame and a template object exist only in their own document, so selecting one
// A node, a frame, a clip and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'song')
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`);
+4
View File
@@ -15,6 +15,7 @@ import {
} from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveClipCommand } from '../core/commands/clipCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
@@ -212,6 +213,9 @@ export function removeSelection(state: EditorState): boolean {
case 'frame':
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
return true;
case 'clip':
state.execute(new RemoveClipCommand(s.assetId, s.clipId));
return true;
case 'shape':
removeShapes(state, s.assetId, s.shapeIds);
return true;
+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) => {
+16 -11
View File
@@ -7,6 +7,7 @@ import { assetSignature, buildAssetSection, refreshAssetSection } from './assetS
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import { buildClipSection, clipSignature, refreshClipSection } from './clipSection';
import { emptyHost, type InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
@@ -131,6 +132,7 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId);
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
@@ -147,6 +149,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId);
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
@@ -177,17 +180,19 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.kind === 'shape'
? refreshShapeSection(this.host, s.assetId, s.shapeId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'track'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.kind === 'edge'
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.kind === 'song'
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
: s.kind === 'clip'
? refreshClipSection(this.host, s.assetId, s.clipId)
: s.kind === 'shape'
? refreshShapeSection(this.host, s.assetId, s.shapeId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'track'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.kind === 'edge'
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.kind === 'song'
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+72
View File
@@ -0,0 +1,72 @@
import { RemoveClipCommand, RenameClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { SoundAsset, SoundClip } from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
import { clipsOf } from '../../core/model/sound';
import { CLIP_ROWS } from '../../core/properties/sounds';
import { Button } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a clip selection: none – only the clip disappearing rebuilds. */
export function clipSignature(project: Project, assetId: string, clipId: string): string {
return currentClip(project, assetId, clipId) ? 'clip' : 'missing';
}
/** Name, the stretch of the file it is and how it is played, and the way to delete it (#357). */
export function buildClipSection(host: InspectorHost, assetId: string, clipId: string): void {
const asset = currentSound(host.state.project, assetId);
const clip = asset && currentClip(host.state.project, assetId, clipId);
if (!asset || !clip) return;
addTitle(host, 'SOUND CLIP');
addNameField(host, clip.name, {
onChange: (name) => {
// The command rejects a blank name but cannot put the old text back into the field.
if (name.trim()) host.state.execute(new RenameClipCommand(assetId, clipId, name.trim()));
else host.nameField?.setValue(clip.name);
},
});
addNote(host, `Clip of the sound "${asset.name}". Its volume, loop, bus and sound FX are the sound's.`);
for (const def of CLIP_ROWS) {
const handlers = previewingHandlers<SoundClip>(host, {
target: () => currentClip(host.state.project, assetId, clipId),
read: (target) => target.props[def.key],
write: (target, value) => (target.props[def.key] = value),
remove: (target) => delete target.props[def.key],
commit: (value) =>
new SetClipPropsCommand(assetId, clipId, { [def.key]: value }, `Set clip ${def.label}`),
});
const row = createFieldRow(host, def, clip.props[def.key] ?? def.default, handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
}
addGap(host);
host.body.add(
new Button('Delete clip', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveClipCommand(assetId, clipId)),
}),
);
}
/** Pushes the clip's current name and values into the rows. False when it is gone (rebuild). */
export function refreshClipSection(host: InspectorHost, assetId: string, clipId: string): boolean {
const clip = currentClip(host.state.project, assetId, clipId);
if (!clip) return false;
host.nameField?.setValue(clip.name);
for (const def of CLIP_ROWS) host.fields.get(def.key)?.setValue(clip.props[def.key] ?? def.default);
return true;
}
/** Looked up inside the sound, so a selection left over from a deleted sound edits nothing. */
function currentClip(project: Project, assetId: string, clipId: string): SoundClip | undefined {
const asset = currentSound(project, assetId);
return asset && clipsOf(asset).find((c) => c.id === clipId);
}
function currentSound(project: Project, assetId: string): SoundAsset | undefined {
return findAssetOf(project, assetId, 'sound');
}
+9 -2
View File
@@ -16,6 +16,8 @@ import {
assetRefLabel,
MASTER_LABEL,
NO_ASSET_LABEL,
soundChoices,
soundEntries,
} from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx';
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
@@ -408,7 +410,12 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
...assetRefChoices(project, extras?.assetTypes)
.filter((asset) => !left.has(asset.id))
.map((asset) => ({ id: asset.id, label: assetChoiceLabel(asset) })),
.flatMap((asset) =>
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
asset.type === 'sound'
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
),
];
},
},
@@ -429,7 +436,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
sound: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
choices: soundChoices,
},
scene: {
none: NO_ASSET_LABEL,
+27 -2
View File
@@ -1,10 +1,14 @@
import { SetSongCommand } from '../../core/commands/songCommands';
import type { SongAsset } from '../../core/model/Asset';
import { ASSET_TYPE_LABELS } from '../../core/model/AssetTypes';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import { findAssetOf, type Project } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { isEmbedded } from '../../core/music/embedded';
import { assetRefLabel, soundEntries } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import {
ITEM_MIN_DB,
itemAsset,
itemMaxDb,
MAX_TRANSPOSE,
type SongItem,
@@ -12,7 +16,9 @@ import {
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../core/music/song';
import type { MenuEntry } from '../../ui';
import { ownFxEntries } from '../song/ownFx';
import type { EditorState } from '../state';
import { createFieldRow } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -180,7 +186,7 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
const { state } = host;
addTitle(host, 'SONG ITEM');
const track = asset.tracks.find((t) => t.id === item.track);
const what = findAsset(state.project, item.asset)?.name ?? 'missing';
const what = itemAsset(state.project, item) ? assetRefLabel(state.project, item.asset) : 'missing';
const others = picked > 1 ? ` ${picked - 1} more picked.` : '';
addNote(host, `${ASSET_TYPE_LABELS[item.kind]} "${what}" on ${track?.name ?? 'a track'}.${others}`);
for (const def of itemRows(item)) {
@@ -198,6 +204,8 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
});
if (def.key === FX_ROW.key)
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
if (def.key === 'asset' && item.kind === 'sound')
handlers.moreChoices = (current) => ownSoundEntries(state, current, (id) => state.execute(commit(id)));
const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -211,6 +219,23 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
);
}
/**
* The sound the song keeps for the item (#355) and its clips (#357), which no list offers: a take's
* clips are picked here.
*/
function ownSoundEntries(state: EditorState, current: string, pick: (id: string) => void): MenuEntry[] {
const sound = findSound(state.project, current)?.asset;
if (!sound || !isEmbedded(sound)) return [];
return [
{ separator: true },
...soundEntries(sound).map((entry) => ({
label: entry.label,
current: entry.id === current,
onSelect: () => pick(entry.id),
})),
];
}
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
const id = itemIds.at(-1);
return id === undefined ? undefined : asset?.items.find((i) => i.id === id);
+5 -1
View File
@@ -20,6 +20,7 @@ import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes }
import { ticksOfSeconds } from '../../core/music/time';
import type { Asset, SongAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { textOf } from '../../core/model/Properties';
import { assetFxOf } from '../../core/properties/assetFx';
import type { SoundStore } from '../../core/store/SoundStore';
@@ -156,7 +157,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
itemId: item.id,
round: roundAt(item, patternLength(pattern), tick),
});
activateAsset(state, item.asset);
// An item playing a clip (#357) opens its sound with the clip picked.
const sound = item.kind === 'sound' ? findSound(state.project, item.asset) : null;
if (sound?.clip) state.select({ kind: 'clip', assetId: sound.asset.id, clipId: sound.clip.id });
else activateAsset(state, item.asset);
};
timeline.onDiscardTake = () => this.stop({ discard: true });
this.mount(timeline, [
+15 -12
View File
@@ -14,13 +14,13 @@ import {
embeddedUsedBy,
instrumentNear,
isEmbedded,
markedName,
listedAssets,
songClockProps,
} from '../../core/music/embedded';
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
import type { LoopRange } from '../../core/music/sequence';
import {
itemAsset,
itemEnd,
newItem,
newTrack,
@@ -54,6 +54,7 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { assetRefLabel } from '../../core/properties/assetRef';
import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { TimeRuler } from '../music/TimeRuler';
@@ -959,7 +960,7 @@ export class SongTimeline extends Widget {
if (dragged.type === 'pattern')
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
else {
const played = playedSoundOf(this.state.project, dragged);
const played = playedSoundOf({ asset: dragged, clip: null });
this.ensureLoaded(played);
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
}
@@ -1030,14 +1031,14 @@ export class SongTimeline extends Widget {
const hit = at && asset.items.find((i) => i.id === at.id);
if (!hit) return this.openSpotMenu(asset, g, global);
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
const played = findAsset(this.state.project, hit.asset);
const played = itemAsset(this.state.project, hit);
const embedded = played !== undefined && isEmbedded(played);
new Menu([
{ label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) },
embedded
played && embedded
? {
label: 'Make project asset',
onSelect: () => this.state.execute(new MakeProjectAssetCommand(hit.asset)),
onSelect: () => this.state.execute(new MakeProjectAssetCommand(played.id)),
}
: { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) },
{ separator: true },
@@ -1087,12 +1088,14 @@ export class SongTimeline extends Widget {
/** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */
embedCopy(asset: SongAsset, item: SongItem): boolean {
const played = findAsset(this.state.project, item.asset);
const played = itemAsset(this.state.project, item);
if (!played || isEmbedded(played)) return false;
const copy = embeddedCopy(played, asset);
// An item playing a clip (#357) plays the same clip of the copy.
const { copy, ids } = embeddedCopy(played, asset);
const named = ids.get(item.asset) ?? copy.id;
this.commit(
asset,
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] },
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: named } : i)), spare: [copy] },
'Embed a copy',
);
return true;
@@ -1244,10 +1247,10 @@ export class SongTimeline extends Widget {
return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : [];
}
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
/** Its asset's name, with a mark in front for one the song keeps to itself (#355); "Sound / Clip" for a clip (#357). */
private labelOf(item: SongItem): string {
const asset = findAsset(this.state.project, item.asset);
return asset ? markedName(asset) : 'missing';
const project = this.state.project;
return itemAsset(project, item) ? assetRefLabel(project, item.asset) : 'missing';
}
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
@@ -1394,7 +1397,7 @@ export class SongTimeline extends Widget {
if (row === undefined) continue;
const box = itemBox(this.view, row, item);
if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue;
const missing = !findAsset(this.state.project, item.asset);
const missing = !itemAsset(this.state.project, item);
const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR;
const lit = picked.has(item.id);
this.itemsG
+55 -48
View File
@@ -1,5 +1,6 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { Asset, SoundAsset } from '../../core/model/Asset';
import { SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { Asset, SoundAsset, SoundClip } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { playedSoundOf } from '../../core/properties/playedSound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { FADE_CURVES, type FadeCurve, fadeGain } from '../../core/model/sound';
@@ -52,21 +53,20 @@ function curveLabel(curve: FadeCurve | undefined): string {
}
/**
* The Sound tab (#343): the file as a waveform, and the stretch of it the asset is. That stretch is
* the sample – a song item, a `Play sound` and an instrument's Sample all play it and nothing else.
* The header plays it (Space does too), turns it backwards, sets the fade at each edge and its
* curve, and puts the whole file back.
* The Sound tab (#343): the file as a waveform, and its clips (#357), the stretches of it a song item,
* a `Play sound` or an instrument's Sample can play instead of the whole file. The header plays the
* picked clip, else the whole file (Space does too), and turns the picked clip backwards and sets the
* fade at each of its edges and its curve.
*/
export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly wholeButton: Button;
readonly reverseButton: Button;
readonly fadeInField: NumberField;
readonly fadeOutField: NumberField;
readonly curveInButton: Dropdown;
readonly curveOutButton: Dropdown;
/** The voice the tab plays, 0 while none, and the sound it is of. */
/** The voice the tab plays, 0 while none, and the sound and clip it is of ("sound:clip"). */
private voice = 0;
private playingId = '';
private readonly halt = () => this.stop();
@@ -76,17 +76,13 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.playButton = new Button('Play', {
icon: TOOLBAR_ICON_SVG.play,
minWidth: 72,
tooltip: 'Play the stretch between the handles (Space)',
tooltip: 'Play the picked clip, else the whole file (Space)',
onClick: () => this.togglePlay(),
});
this.wholeButton = new Button('Whole file', {
tooltip: 'Play the whole file again: the handles go back to its start and end',
onClick: () => this.canvas.resetCut(),
});
this.reverseButton = new Button('', {
icon: HEADER_ICON_SVG.reverse,
toggle: true,
tooltip: 'Reverse: play the stretch backwards, from its end to its start',
tooltip: 'Reverse: play the picked clip backwards, from its end to its start',
onClick: () => this.toggleReverse(),
});
const fadeField = (key: string) =>
@@ -117,7 +113,6 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
canvas.onLoaded = () => this.refresh();
this.mount(canvas, [
this.playButton,
this.wholeButton,
this.reverseButton,
// In milliseconds, as the inspector says; the header has no room for the unit.
new Label('Fade in', { style: 'muted' }),
@@ -128,6 +123,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.curveOutButton,
]);
state.on('load', () => this.stop());
// Picking a clip changes what the header plays and edits.
state.on('selection', () => this.refresh());
}
override selectedId(): string {
@@ -147,19 +144,20 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
else this.play();
}
/** Plays the stretch once, at the sound's own volume, from its start. */
/** Plays the picked clip or the whole file once, at the sound's own volume, from its start. */
play(): void {
const asset = this.asset();
if (!asset?.file) return;
const clip = this.canvas.pickedClip();
this.stop();
claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352), its bus and the master, as the game hears it (#325).
const bus = playedBus(this.state.project, '', props);
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
const file = playedSoundOf(this.state.project, asset);
const file = playedSoundOf({ asset, clip });
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
this.playingId = this.voice ? asset.id : '';
this.playingId = this.voice ? `${asset.id}:${clip?.id ?? ''}` : '';
this.syncPlayButton();
}
@@ -174,7 +172,7 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
}
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
if (this.playingId.startsWith(`${assetId}:`)) this.stop();
}
/** Only called while the tab is visible: the playhead follows what is heard, and stops with it. */
@@ -190,37 +188,43 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.canvas.invalidate();
}
private toggleReverse(): void {
/** Sets one value of the picked clip, in one undo step. */
private setOnClip(key: string, value: PropertyValue, label: string): void {
const asset = this.asset();
if (!asset) return;
const reverse = cutOf(resolveAssetProps(this.state.project, asset)).reverse;
this.state.execute(new SetAssetPropertyCommand(asset.id, REVERSE_KEY, !reverse));
const clip = this.canvas.pickedClip();
if (asset && clip)
this.state.execute(new SetClipPropsCommand(asset.id, clip.id, { [key]: value }, label));
}
private toggleReverse(): void {
const clip = this.canvas.pickedClip();
if (clip) this.setOnClip(REVERSE_KEY, !cutOf(clip.props).reverse, 'Reverse clip');
}
private setFade(key: string, ms: number): void {
const asset = this.asset();
const cut = asset && cutOf(resolveAssetProps(this.state.project, asset));
if (!asset || !cut) return;
const clip = this.canvas.pickedClip();
if (!clip) return;
const cut = cutOf(clip.props);
const now = Math.round((key === FADE_IN_KEY ? cut.fadeIn : cut.fadeOut) * 1000);
if (ms !== now) this.state.execute(new SetAssetPropertyCommand(asset.id, key, ms));
if (ms !== now) this.setOnClip(key, ms, 'Set clip fade');
}
/** The curve a key stands for, as the sound has it now. */
private curveOf(asset: SoundAsset, key: string): FadeCurve {
const cut = cutOf(resolveAssetProps(this.state.project, asset));
/** The curve a key stands for, as the clip has it now. */
private curveOf(clip: SoundClip, key: string): FadeCurve {
const cut = cutOf(clip.props);
return key === FADE_IN_CURVE_KEY ? cut.curveIn : cut.curveOut;
}
/** The curve a dropdown stands for on the sound shown, the default while there is none. */
/** The curve a dropdown stands for on the picked clip, the default while there is none. */
shownCurve(key: string): FadeCurve {
const asset = this.asset();
return asset ? this.curveOf(asset, key) : DEFAULT_FADE_CURVE;
const clip = this.canvas.pickedClip();
return clip ? this.curveOf(clip, key) : DEFAULT_FADE_CURVE;
}
private openCurveMenu(key: string, button: Dropdown, rising: boolean): void {
const asset = this.asset();
if (!asset) return;
const now = this.curveOf(asset, key);
const clip = this.canvas.pickedClip();
if (!clip) return;
const now = this.curveOf(clip, key);
new Menu(
FADE_CURVES.map((curve) => ({
label: curve.label,
@@ -232,9 +236,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
}
private setCurve(key: string, curve: FadeCurve): void {
const asset = this.asset();
if (!asset || this.curveOf(asset, key) === curve) return;
this.state.execute(new SetAssetPropertyCommand(asset.id, key, curve));
const clip = this.canvas.pickedClip();
if (clip && this.curveOf(clip, key) !== curve) this.setOnClip(key, curve, 'Set clip fade curve');
}
private syncPlayButton(): void {
@@ -254,21 +257,25 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
protected override syncExtras(asset: SoundAsset | null): void {
const ready = asset !== null && Boolean(asset.file);
const clip = ready ? this.canvas.pickedClip() : null;
this.fitButton.setEnabled(ready);
this.wholeButton.setEnabled(ready);
this.reverseButton.setEnabled(ready);
this.fadeInField.setEnabled(ready);
this.fadeOutField.setEnabled(ready);
this.curveInButton.setEnabled(ready);
this.curveOutButton.setEnabled(ready);
const cut = asset ? cutOf(resolveAssetProps(this.state.project, asset)) : null;
// What a clip has: with none picked there is nothing to turn or fade.
for (const control of [
this.reverseButton,
this.fadeInField,
this.fadeOutField,
this.curveInButton,
this.curveOutButton,
])
control.setEnabled(clip !== null);
const cut = clip ? cutOf(clip.props) : null;
this.reverseButton.setToggled(cut?.reverse ?? false);
this.fadeInField.setNumber(Math.round((cut?.fadeIn ?? 0) * 1000));
this.fadeOutField.setNumber(Math.round((cut?.fadeOut ?? 0) * 1000));
this.curveInButton.setIcon(curveIcon(cut?.curveIn ?? DEFAULT_FADE_CURVE, true));
this.curveOutButton.setIcon(curveIcon(cut?.curveOut ?? DEFAULT_FADE_CURVE, false));
// Another sound on screen: what was playing is no longer what the tab shows.
if (this.isPlaying && asset?.id !== this.playingId) this.stop();
// Another sound or clip on screen: what was playing is no longer what the tab shows.
if (this.isPlaying && `${asset?.id}:${clip?.id ?? ''}` !== this.playingId) this.stop();
this.syncPlayButton();
}
}
+200 -82
View File
@@ -5,19 +5,19 @@ import {
Graphics,
type Text,
} from 'pixi.js';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { SoundAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { SoundAsset, SoundClip } from '../../core/model/Asset';
import type { PropertyBag, Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { clipsOf, cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds';
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { EditorState } from '../state';
import {
clampWave,
type ClipBand,
clipAt,
type CutSpan,
draggedSpan,
edgeAt,
@@ -39,6 +39,10 @@ import {
} from './waveView';
const RULER_HEIGHT = 24;
/** The strip under the ruler the clips stand in as bands (#357). */
const STRIP_HEIGHT = 20;
/** Where the wave starts, under the ruler and the strip. */
const WAVE_TOP = RULER_HEIGHT + STRIP_HEIGHT;
/** The level scale left of the wave, in decibels (#357). */
const SCALE_WIDTH = 36;
/** Pixels apart the level scale's marks are at least. */
@@ -53,18 +57,27 @@ const LABEL_SPACING = 70;
const LEAST_SPAN = 0.001;
/** Pixels a press has to travel before it spans a new stretch. */
const DRAG_START = 3;
/** The name on the picked clip's band, dark on its light fill. */
const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
/** Line pieces a fade's curve is drawn with at most. */
const RAMP_STEPS = 32;
/**
* A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip.
* `picks` is what a press in the strip selects when it does not move: a clip, or null for the sound.
*/
type Gesture =
| { kind: 'pan'; pointerX: number; panX: number }
| {
kind: 'span';
clipId: string | null;
edge: 'start' | 'end' | null;
from: number;
pointerX: number;
moved: boolean;
span: CutSpan;
picks?: string | null;
};
/** A file's samples as the canvas draws them: decoded once, its block peaks made on first sight. */
@@ -88,10 +101,12 @@ function waveOf(buffer: AudioBuffer): Wave {
/**
* The Sound tab's canvas (#343): the whole file as one waveform, the loudest of its channels, under
* a ruler in seconds. The stretch the asset is cut to stands between two handles, what lies outside
* it darker; dragging a handle moves that edge, dragging beside them spans a new stretch. One drag
* is one undo step. The wheel zooms, the middle button pans, as in the music tabs; over the level
* scale on the left the wheel zooms the wave's height (#357).
* a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler: a
* click picks one, a drag there spans a new one. The picked clip stands between two handles, what
* lies outside it darker; dragging a handle moves that edge, dragging beside them spans it anew.
* With no clip picked, a drag over the wave spans a new one too. One drag is one undo step. The
* wheel zooms, the middle button pans, as in the music tabs; over the level scale on the left the
* wheel zooms the wave's height.
*/
export class WaveCanvas extends Widget {
private readonly bg = new Graphics();
@@ -101,6 +116,8 @@ export class WaveCanvas extends Widget {
private readonly waveG = new Graphics();
private readonly overlay = new Graphics();
private readonly labels = new Container();
private readonly stripLabels = new Container();
private readonly stripTexts: Text[] = [];
/** Wave, cut and ruler, kept to the widget: a handle panned out of view must not draw beside it. */
private readonly content = new Container();
private readonly clip = new Graphics();
@@ -124,10 +141,11 @@ export class WaveCanvas extends Widget {
super();
this.focusable = true;
this.labels.eventMode = 'none';
this.stripLabels.eventMode = 'none';
this.scaleLabels.eventMode = 'none';
this.hint.eventMode = 'none';
this.content.x = SCALE_WIDTH;
this.content.addChild(this.waveG, this.overlay, this.labels);
this.content.addChild(this.waveG, this.overlay, this.labels, this.stripLabels);
this.content.mask = this.clip;
this.addChild(this.bg, this.scaleG, this.scaleLabels, this.content, this.clip, this.hint);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
@@ -135,6 +153,7 @@ export class WaveCanvas extends Widget {
this.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.invalidate());
state.on('selection', () => this.invalidate());
}
override measure(): Size {
@@ -187,29 +206,66 @@ export class WaveCanvas extends Widget {
/** The middle of the level scale on screen (test hook). */
scaleCentre(): Vec2 {
const origin = this.getGlobalPosition();
return { x: origin.x + SCALE_WIDTH / 2, y: origin.y + RULER_HEIGHT + this.waveHeight / 2 };
return { x: origin.x + SCALE_WIDTH / 2, y: origin.y + WAVE_TOP + this.waveHeight / 2 };
}
/** Where the top of a full-scale wave is drawn on screen, before it is held inside the tab (test hook). */
fullScaleTop(): number {
return (
this.getGlobalPosition().y +
RULER_HEIGHT +
this.waveHeight / 2 -
levelOffset(1, this.level, this.fullHalf)
this.getGlobalPosition().y + WAVE_TOP + this.waveHeight / 2 - levelOffset(1, this.level, this.fullHalf)
);
}
/** The stretch the asset is cut to, as shown – while a drag runs, as it would leave it. */
/**
* What the tab plays: the picked clip, else the whole file. While a drag runs, the span it would
* leave.
*/
shownSpan(): CutSpan | null {
const asset = this.asset();
const wave = this.wave();
if (!asset || !wave) return null;
if (this.gesture?.kind === 'span') return this.gesture.span;
const cut = cutOf(resolveAssetProps(this.state.project, asset));
if (!this.asset() || !wave) return null;
return this.shownClipSpan() ?? { start: 0, end: wave.duration };
}
/** The picked clip of the sound shown, null for none (#357). */
pickedClip(): SoundClip | null {
const asset = this.asset();
const id = this.state.selectedClipId;
return (asset && id && clipsOf(asset).find((c) => c.id === id)) || null;
}
/** The span handles stand at: a drag's, else the picked clip's; null with neither. */
private shownClipSpan(): CutSpan | null {
const wave = this.wave();
if (!wave) return null;
if (this.gesture?.kind === 'span' && this.gesture.moved) return this.gesture.span;
const clip = this.pickedClip();
return clip ? this.clipSpan(clip, wave) : null;
}
private clipSpan(clip: SoundClip, wave: Wave): CutSpan {
const cut = cutOf(clip.props);
return spanOf(cut.start, cut.end, wave.duration);
}
/** The clips as their bands in the strip, a clip being dragged where the drag would leave it. */
private bands(asset: SoundAsset, wave: Wave): ClipBand[] {
const g = this.gesture?.kind === 'span' && this.gesture.moved ? this.gesture : null;
const bands = clipsOf(asset).map((clip) => ({
id: clip.id,
span: g?.clipId === clip.id ? g.span : this.clipSpan(clip, wave),
}));
return g && g.clipId === null ? [...bands, { id: '', span: g.span }] : bands;
}
/** The middle of the strip at a second of the file, on screen (test hook). */
stripAt(seconds: number): Vec2 {
const origin = this.getGlobalPosition();
return {
x: origin.x + SCALE_WIDTH + secondsToX(this.view, seconds),
y: origin.y + RULER_HEIGHT + STRIP_HEIGHT / 2,
};
}
/** The file's length in seconds, null while it is not loaded (test hook). */
fileDuration(): number | null {
return this.wave()?.duration ?? null;
@@ -220,17 +276,10 @@ export class WaveCanvas extends Widget {
const origin = this.getGlobalPosition();
return {
x: origin.x + SCALE_WIDTH + secondsToX(this.view, seconds),
y: origin.y + RULER_HEIGHT + this.waveHeight / 2,
y: origin.y + WAVE_TOP + this.waveHeight / 2,
};
}
/** Puts the whole file back, in one undo step. */
resetCut(): void {
const asset = this.asset();
const wave = this.wave();
if (asset && wave) this.commit(asset, { start: 0, end: wave.duration }, wave, 'Reset sound cut');
}
private asset(): SoundAsset | null {
return (this.assetId && findAssetOf(this.state.project, this.assetId, 'sound')) || null;
}
@@ -252,7 +301,7 @@ export class WaveCanvas extends Widget {
}
private get waveHeight(): number {
return Math.max(0, this.h - RULER_HEIGHT);
return Math.max(0, this.h - WAVE_TOP);
}
/** Pixels from the middle a full-scale sample stands at level 1. */
@@ -278,9 +327,9 @@ export class WaveCanvas extends Widget {
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
const wave = this.wave();
const span = this.shownSpan();
if (!wave || !span) return;
if (!asset || !wave) return;
const p = this.areaPoint(e);
e.stopPropagation();
ui().focus.focus(this);
@@ -290,14 +339,26 @@ export class WaveCanvas extends Widget {
}
if (e.button !== 0 || p.x < 0) return;
const from = Math.max(0, Math.min(wave.duration, xToSeconds(this.view, p.x)));
this.gesture = {
const start = {
kind: 'span',
edge: edgeAt(this.view, span, p.x),
from,
pointerX: p.x,
moved: false,
span,
};
span: { start: from, end: from },
} as const;
if (p.y >= RULER_HEIGHT && p.y < WAVE_TOP) {
// In the strip a press picks the band under it, a drag spans a new clip.
this.gesture = {
...start,
clipId: null,
edge: null,
picks: clipAt(this.view, this.bands(asset, wave), p.x),
};
return;
}
const clip = this.pickedClip();
const span = clip && this.clipSpan(clip, wave);
this.gesture = { ...start, clipId: clip?.id ?? null, edge: span ? edgeAt(this.view, span, p.x) : null };
}
private onPointerMove(e: FederatedPointerEvent): void {
@@ -316,8 +377,9 @@ export class WaveCanvas extends Widget {
}
if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return;
g.moved = true;
const before = this.committedSpan(wave);
if (!before) return;
// Only the picked clip has handles: an edge moves that one.
const clip = this.pickedClip();
const before = clip && clip.id === g.clipId ? this.clipSpan(clip, wave) : g.span;
const at = xToSeconds(this.view, p.x);
g.span = draggedSpan(before, g.edge, g.from, at, wave.duration, LEAST_SPAN);
this.redraw();
@@ -326,28 +388,42 @@ export class WaveCanvas extends Widget {
private onPointerUp(): void {
const g = this.gesture;
this.gesture = null;
if (g?.kind !== 'span' || !g.moved) return this.redraw();
if (g?.kind !== 'span') return this.redraw();
const asset = this.asset();
const wave = this.wave();
if (asset && wave) this.commit(asset, g.span, wave, g.edge ? 'Move sound cut' : 'Cut sound');
if (!g.moved) {
if (asset && g.picks !== undefined)
this.state.select(
g.picks
? { kind: 'clip', assetId: asset.id, clipId: g.picks }
: { kind: 'asset', assetId: asset.id },
);
return this.redraw();
}
if (asset && wave) this.commit(asset, g, wave);
this.redraw();
}
private committedSpan(wave: Wave): CutSpan | null {
const asset = this.asset();
if (!asset) return null;
const cut = cutOf(resolveAssetProps(this.state.project, asset));
return spanOf(cut.start, cut.end, wave.duration);
}
/** Writes a span as the asset's start and end, both in one step; nothing where neither changes. */
private commit(asset: SoundAsset, span: CutSpan, wave: Wave, label: string): void {
const next = storedCut(span, wave.duration, wave.rate);
const now = cutOf(resolveAssetProps(this.state.project, asset));
const commands = [];
if (next.start !== now.start) commands.push(new SetAssetPropertyCommand(asset.id, START_KEY, next.start));
if (next.end !== now.end) commands.push(new SetAssetPropertyCommand(asset.id, END_KEY, next.end));
if (commands.length > 0) this.state.execute(new CompoundCommand(label, commands));
/**
* Writes a drag's span as the clip's start and end in one step, nothing where neither changes – or
* as a new clip, which is then picked.
*/
private commit(asset: SoundAsset, g: Extract<Gesture, { kind: 'span' }>, wave: Wave): void {
const next = storedCut(g.span, wave.duration, wave.rate);
if (g.clipId === null) {
const add = new AddClipCommand(asset.id, { [START_KEY]: next.start, [END_KEY]: next.end });
if (this.state.execute(add)) this.state.select({ kind: 'clip', assetId: asset.id, clipId: add.clipId });
return;
}
const clip = clipsOf(asset).find((c) => c.id === g.clipId);
if (!clip) return;
const now = cutOf(clip.props);
const values: PropertyBag = {};
if (next.start !== now.start) values[START_KEY] = next.start;
if (next.end !== now.end) values[END_KEY] = next.end;
const label = g.edge ? 'Move clip edge' : 'Span clip';
if (Object.keys(values).length > 0)
this.state.execute(new SetClipPropsCommand(asset.id, clip.id, values, label));
}
private onWheel(e: FederatedWheelEvent): void {
@@ -362,8 +438,8 @@ export class WaveCanvas extends Widget {
}
private updateCursor(x: number, y: number): void {
const span = this.shownSpan();
const inside = x >= 0 && x <= this.areaWidth && y >= 0 && y <= this.h;
const span = this.shownClipSpan();
const inside = x >= 0 && x <= this.areaWidth && y >= WAVE_TOP && y <= this.h;
const cursor = inside && span && edgeAt(this.view, span, x) ? 'ew-resize' : 'default';
if (this.cursor !== cursor) this.cursor = cursor;
}
@@ -382,14 +458,15 @@ export class WaveCanvas extends Widget {
this.bg.clear().rect(0, 0, this.w, h).fill(c.viewport);
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
this.bg.rect(0, RULER_HEIGHT, SCALE_WIDTH, wh).fill(c.panelHeader);
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, wh).fill(c.border);
this.bg.rect(0, RULER_HEIGHT, this.w, STRIP_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, WAVE_TOP - 1, this.w, 1).fill(c.border);
this.bg.rect(0, WAVE_TOP, SCALE_WIDTH, wh).fill(c.panelHeader);
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, STRIP_HEIGHT + wh).fill(c.border);
this.clip.clear().rect(SCALE_WIDTH, 0, w, h).fill(0xffffff);
this.scaleG.clear();
this.waveG.clear();
this.overlay.clear();
const asset = this.asset();
const span = this.shownSpan();
this.hint.visible = !wave;
this.hint.text = !asset
? 'No sound selected – pick one in the Assets panel.'
@@ -398,26 +475,28 @@ export class WaveCanvas extends Widget {
: soundLibrary.failedToLoad(asset.file)
? 'Could not be decoded.'
: 'Loading…';
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
if (!wave || !span || !asset) {
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, WAVE_TOP + Theme.padding * 2);
if (!wave || !asset) {
this.syncLabels([]);
this.syncScale([]);
this.syncStrip([]);
return;
}
const x0 = secondsToX(this.view, 0);
const x1 = secondsToX(this.view, wave.duration);
const mid = RULER_HEIGHT + wh / 2;
const mid = WAVE_TOP + wh / 2;
const half = this.level * this.fullHalf;
const room = wh / 2;
// The file itself a shade lighter than what lies before and after it.
const left = Math.max(0, x0);
const right = Math.min(w, x1);
if (right > left)
this.waveG.rect(left, RULER_HEIGHT, right - left, wh).fill({ color: c.panel, alpha: 0.5 });
if (right > left) this.waveG.rect(left, WAVE_TOP, right - left, wh).fill({ color: c.panel, alpha: 0.5 });
if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
this.drawWave(wave, left, right, mid, half, room);
this.drawRuler(left, right);
this.drawCut(asset, wave, span, mid, half, room);
this.drawStrip(asset, wave);
const span = this.shownClipSpan();
if (span) this.drawCut(wave, span, mid, half, room);
this.drawScale(mid, room);
if (this.playhead !== null) {
const x = secondsToX(this.view, this.playhead);
@@ -475,25 +554,45 @@ export class WaveCanvas extends Widget {
this.syncLabels(placed);
}
/** A band per clip with its name, the picked one lit, and the one a drag spans. */
private drawStrip(asset: SoundAsset, wave: Wave): void {
const c = Theme.colors;
const picked =
this.gesture?.kind === 'span' && this.gesture.moved ? this.gesture.clipId : this.pickedClip()?.id;
const names = new Map(clipsOf(asset).map((clip) => [clip.id, clip.name]));
const placed: { text: string; x: number; room: number; lit: boolean }[] = [];
for (const { id, span } of this.bands(asset, wave)) {
const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end);
if (b < 0 || a > this.areaWidth) continue;
const lit = id === (picked ?? '');
this.overlay
.rect(a, RULER_HEIGHT + 2, Math.max(1, b - a), STRIP_HEIGHT - 4)
.fill({ color: c.accent, alpha: lit ? 0.85 : 0.35 })
.stroke({ width: 1, color: lit ? c.accentText : c.accent, alpha: lit ? 1 : 0.7, alignment: 1 });
const name = names.get(id);
if (name)
placed.push({
text: name,
x: Math.max(a, 0) + 4,
room: Math.min(b, this.areaWidth) - Math.max(a, 0) - 8,
lit,
});
}
this.syncStrip(placed);
}
/** Dark outside the stretch, a handle at each edge, and the ramp at each edge inside the file. */
private drawCut(
asset: SoundAsset,
wave: Wave,
span: CutSpan,
mid: number,
half: number,
room: number,
): void {
private drawCut(wave: Wave, span: CutSpan, mid: number, half: number, room: number): void {
const c = Theme.colors;
const { h } = this;
const w = this.areaWidth;
const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end);
const shade = { color: 0x000000, alpha: 0.55 };
if (a > 0) this.overlay.rect(0, RULER_HEIGHT, a, h - RULER_HEIGHT).fill(shade);
if (b < w) this.overlay.rect(b, RULER_HEIGHT, w - b, h - RULER_HEIGHT).fill(shade);
this.overlay.rect(a, 0, Math.max(1, b - a), 4).fill({ color: c.accent, alpha: 0.6 });
const cut = cutOf(resolveAssetProps(this.state.project, asset));
if (a > 0) this.overlay.rect(0, WAVE_TOP, a, h - WAVE_TOP).fill(shade);
if (b < w) this.overlay.rect(b, WAVE_TOP, w - b, h - WAVE_TOP).fill(shade);
const cut = cutOf(this.pickedClip()?.props ?? {});
const range = cutRange(wave.channels[0]!.length, wave.rate, { ...cut, ...span });
const ramp = (from: number, to: number, rising: boolean, curve: FadeCurve) => {
const xa = secondsToX(this.view, from);
@@ -521,12 +620,12 @@ export class WaveCanvas extends Widget {
[b, 'end'],
] as const) {
const held = this.gesture?.kind === 'span' && this.gesture.edge === edge;
this.overlay.rect(x - 1, 0, 2, h).fill(held ? c.hot : c.accent);
this.overlay.rect(x - 1, WAVE_TOP, 2, h - WAVE_TOP).fill(held ? c.hot : c.accent);
const toward = edge === 'start' ? 1 : -1;
this.overlay
.moveTo(x, 0)
.lineTo(x + toward * 8, 0)
.lineTo(x, 10)
.moveTo(x, WAVE_TOP)
.lineTo(x + toward * 8, WAVE_TOP)
.lineTo(x, WAVE_TOP + 10)
.closePath()
.fill(held ? c.hot : c.accent);
}
@@ -558,6 +657,25 @@ export class WaveCanvas extends Widget {
for (let i = placed.length; i < this.scaleTexts.length; i++) this.scaleTexts[i]!.visible = false;
}
/** A clip's name inside its band, cut short with "…" where the band is narrower. */
private syncStrip(placed: readonly { text: string; x: number; room: number; lit: boolean }[]): void {
placed.forEach((label, i) => {
let text = this.stripTexts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.stripTexts.push(text);
this.stripLabels.addChild(text);
}
text.style = label.lit ? LIT_LABEL : textStyle('small');
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = text.width <= label.room;
text.position.set(Math.round(label.x), Math.round(RULER_HEIGHT + (STRIP_HEIGHT - text.height) / 2));
});
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
}
private syncLabels(placed: readonly { text: string; x: number }[]): void {
placed.forEach((label, i) => {
let text = this.texts[i];
+16 -4
View File
@@ -147,10 +147,22 @@ export function draggedSpan(
return b - a >= gap ? { start: a, end: b } : { start: a, end: Math.min(duration, a + gap) };
}
/**
* What an asset stores for a span: seconds, an end at the end of the file as 0 – so a span over the
* whole file is no cut at all, and plays the file itself.
*/
/** A clip of the sound as its band in the strip (#357). */
export interface ClipBand {
id: string;
span: CutSpan;
}
/** The clip whose band is at `x`, the one drawn last where bands lie over each other; null for none. */
export function clipAt(view: WaveView, bands: readonly ClipBand[], x: number): string | null {
for (let i = bands.length - 1; i >= 0; i--) {
const { id, span } = bands[i]!;
if (x >= secondsToX(view, span.start) && x <= secondsToX(view, span.end)) return id;
}
return null;
}
/** What a clip stores for a span: seconds to the nearest sample, an end at the end of the file as 0. */
export function storedCut(span: CutSpan, duration: number, rate: number): { start: number; end: number } {
const sample = 1 / rate;
const start = span.start < sample / 2 ? 0 : roundTo(span.start, rate);
+19 -16
View File
@@ -1,12 +1,12 @@
import type { Asset, SoundFxAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
import { playedSoundOf } from '../../core/properties/playedSound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { listedAssets } from '../../core/music/embedded';
import { playedBus } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
@@ -75,10 +75,10 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
return asset.type === 'soundfx';
}
/** The sound the header plays: the one picked, else the project's first. */
/** The sound or clip (#357) the header plays: the one picked, else the project's first. */
get previewSoundId(): string {
const sounds = listedAssets(this.state.project).filter((a) => a.type === 'sound');
return sounds.some((a) => a.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
const sounds = soundChoices(this.state.project);
return sounds.some((s) => s.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
}
pickSound(id: string): void {
@@ -101,14 +101,14 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
/** Plays the picked sound through the graph; a graph that does not compile is not played at all. */
play(): void {
const asset = this.asset();
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
const sound = findSound(this.state.project, this.previewSoundId);
const plan = asset && soundFxBuildFor(asset, this.state.project).compiled;
if (!sound?.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound);
if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } };
// On the bus of the sound, as the game would play it there (#325).
const bus = playedBus(this.state.project, '', props);
const file = playedSoundOf(this.state.project, sound);
const file = playedSoundOf(sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice);
this.playedFor = asset.id;
@@ -137,11 +137,12 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
protected override syncExtras(asset: SoundFxAsset | null): void {
this.snap.setEnabled(Boolean(asset));
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
this.soundPicker.setText(sound?.name ?? 'No sound');
const id = this.previewSoundId;
const sound = findSound(this.state.project, id);
this.soundPicker.setText(sound ? assetRefLabel(this.state.project, id) : 'No sound');
this.soundPicker.setEnabled(Boolean(asset && sound));
this.playButton.setEnabled(
Boolean(asset && sound?.file && soundFxBuildFor(asset, this.state.project).compiled),
Boolean(asset && sound?.asset.file && soundFxBuildFor(asset, this.state.project).compiled),
);
this.stopButton.setEnabled(Boolean(asset));
this.syncParams(asset);
@@ -190,9 +191,11 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
private openSoundPicker(): void {
const current = this.previewSoundId;
const items = listedAssets(this.state.project)
.filter((a) => a.type === 'sound')
.map((a) => ({ label: a.name, current: a.id === current, onSelect: () => this.pickSound(a.id) }));
if (items.length > 0) new Menu(items).openBelow(this.soundPicker);
const items = soundChoices(this.state.project).map((s) => ({
label: s.label,
current: s.id === current,
onSelect: () => this.pickSound(s.id),
}));
if (items.length > 0) new Menu(items, { searchable: true }).openBelow(this.soundPicker);
}
}
+19 -3
View File
@@ -20,6 +20,7 @@ import {
type Project,
startScene,
} from '../core/model/Project';
import { clipsOf } from '../core/model/sound';
import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui';
import { consoleLog } from './console/log';
@@ -52,6 +53,8 @@ export type Selection =
}
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/** A clip of a sound asset (the Sound tab, #357). */
| { kind: 'clip'; assetId: string; clipId: string }
/**
* One or more shapes of a vector art asset (the Vector art tab, #129). `shapeId` is the primary
* one, whose points and style are edited; `shapeIds` holds all of them for combining (#187).
@@ -727,6 +730,13 @@ export class EditorState {
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'clip') {
const asset = findAssetOf(this.project, s.assetId, 'sound');
// As with a frame: the sound stays open when its clip vanished.
if (!asset) this.select(null);
else if (!clipsOf(asset).some((c) => c.id === s.clipId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'shape') {
const asset = findAssetOf(this.project, s.assetId, 'vectorart');
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
@@ -810,13 +820,18 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
}
/** The sound the selection refers to (#343); the stretch it is cut to is set in its tab. */
/** The sound the selection refers to (#343): the asset, or one of its clips (#357). */
get selectedSoundId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
if (s?.kind !== 'asset' && s?.kind !== 'clip') return null;
return findAssetOf(this.project, s.assetId, 'sound')?.id ?? null;
}
/** The clip of a sound the selection is about (#357); empty for the sound itself. */
get selectedClipId(): string {
return this.selection?.kind === 'clip' ? this.selection.clipId : '';
}
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
get selectedPatternId(): string | null {
const s = this.selection;
@@ -867,7 +882,7 @@ export class EditorState {
private selectedGraphId(type: GraphAsset['type']): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;
if (s.kind === 'frame' || s.kind === 'clip') return null;
// A node names its asset; which tab that is depends on what the asset is. A group, a comment
// and a link name it the same way, so selecting one keeps its graph on screen.
const asset =
@@ -945,6 +960,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'clip' && b.kind === 'clip') return a.assetId === b.assetId && a.clipId === b.clipId;
if (a.kind === 'shape' && b.kind === 'shape')
return a.assetId === b.assetId && a.shapeId === b.shapeId && sameIds(a.shapeIds, b.shapeIds);
if (a.kind === 'track' && b.kind === 'track')
+9 -8
View File
@@ -10,6 +10,7 @@ import type { Vec2 } from '../../core/model/Properties';
import { JOINT_CONNECTED_KEY } from '../../core/model/joint';
import { objectReference } from '../../core/model/objectRefs';
import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { renderLayerAlong } from '../../core/model/renderLayers';
import { pickedFx } from '../../core/properties/assetFx';
import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound';
@@ -283,13 +284,13 @@ function playSound(
tempo: number,
bus: string,
): number {
const asset = findAssetOf(project, assetId, 'sound');
if (!asset?.file) return 0;
const props = resolveAssetProps(project, asset);
const ref = findSound(project, assetId);
if (!ref?.asset.file) return 0;
const props = resolveAssetProps(project, ref.asset);
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
const fx = fxVoiceOf(project, pickedFx(fxId, props), tempo, fxParams);
return audio.play(
playedSoundOf(project, asset),
playedSoundOf(ref),
level,
loop ?? loopsOf(props),
owner,
@@ -463,14 +464,14 @@ export function playClip(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
clip: ScheduledClip,
): number {
const asset = findAssetOf(project, clip.sound, 'sound');
if (!asset?.file) return 0;
const ref = findSound(project, clip.sound);
if (!ref?.asset.file) return 0;
const volume = Math.max(
0,
Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, asset)) * clip.volume),
Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, ref.asset)) * clip.volume),
);
return audio.playClip(
playedSoundOf(project, asset),
playedSoundOf(ref),
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
clipFx(project, clip.fx, clip.tempo),
);
+2 -2
View File
@@ -157,7 +157,7 @@ describe('an embedded asset (#355)', () => {
it('is embedded as a copy of a project asset, which stays', () => {
const { f, song, blipId, edit, track } = setup();
const copy = embeddedCopy(findAsset(f.project, blipId)!, song());
const { copy } = embeddedCopy(findAsset(f.project, blipId)!, song());
edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] });
expect(song().items[0]!.asset).toBe(copy.id);
expect(findAsset(f.project, copy.id)?.owner).toBe(song().id);
@@ -311,7 +311,7 @@ describe('a pattern made in a song (#355)', () => {
it("takes the clock of the song it comes to as a copy, a project pattern's or another song's", () => {
const { f, song, loose, track, edit } = withPatterns();
const copy = embeddedCopy(loose, song()) as PatternAsset;
const copy = embeddedCopy(loose, song()).copy as PatternAsset;
expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]);
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
+219
View File
@@ -0,0 +1,219 @@
/**
* The clips of a sound (#357): stretches of its file with a name and a cut of their own, named by a
* sound field the way a frame is by a texture field. The sound itself is the whole file.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
DuplicateAssetCommand,
} from '../../src/core/commands/assetCommands';
import {
AddClipCommand,
RemoveClipCommand,
RenameClipCommand,
SetClipPropsCommand,
} from '../../src/core/commands/clipCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { SoundAsset } from '../../src/core/model/Asset';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { clipsOf, findSound } from '../../src/core/model/sound';
import { embeddedCopy, embeddedUsedBy } from '../../src/core/music/embedded';
import { sequenceNeeds } from '../../src/core/music/needs';
import { itemFx, newItem, songSequence } from '../../src/core/music/song';
import { assetRefLabel, soundChoices } from '../../src/core/properties/assetRef';
import { playedSoundById } from '../../src/core/properties/playedSound';
import { referencedAssetId } from '../../src/core/properties/references';
import { END_KEY, START_KEY } from '../../src/core/properties/sounds';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { clipAt } from '../../src/editor/sound/waveView';
import { makeProject } from '../helpers';
const FILE = `${'d5'.repeat(32)}.wav`;
const BAR = 1920;
/** A sound "Kick" with two clips, and a song with a take of its own that has one clip. */
function setup() {
const f = makeProject();
const kick = new CreateSoundCommand('Kick', FILE);
f.stack.execute(kick);
const first = new AddClipCommand(kick.asset.id, { [START_KEY]: 0.5, [END_KEY]: 0.75 });
const second = new AddClipCommand(kick.asset.id, { [START_KEY]: 1 });
f.stack.execute(first);
f.stack.execute(second);
const made = new CreateAssetCommand('song', 'Theme');
f.stack.execute(made);
const songId = made.asset.id;
const song = () => findAssetOf(f.project, songId, 'song')!;
const take = new CreateSoundCommand('Take 1', FILE, songId);
f.stack.execute(take);
const takeClip = new AddClipCommand(take.asset.id, { [START_KEY]: 2, [END_KEY]: 3 });
f.stack.execute(takeClip);
const track = song().tracks[0]!.id;
const item = newItem({ track, kind: 'sound', asset: takeClip.clipId, tick: 0, length: BAR });
f.stack.execute(new SetSongCommand(songId, { items: [item] }));
return {
f,
song,
songId,
track,
kick: () => findAssetOf(f.project, kick.asset.id, 'sound')!,
firstId: first.clipId,
secondId: second.clipId,
takeId: take.asset.id,
takeClipId: takeClip.clipId,
};
}
describe('a clip of a sound (#357)', () => {
it('is named "Clip 1", 2 … per sound, and resolves to its sound', () => {
const { f, kick, firstId, secondId, takeId, takeClipId } = setup();
expect(clipsOf(kick()).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']);
expect(findSound(f.project, secondId)).toEqual({ asset: kick(), clip: clipsOf(kick())[1] });
expect(findSound(f.project, kick().id)).toEqual({ asset: kick(), clip: null });
expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId);
expect(findSound(f.project, 'nothing')).toBeNull();
expect(findSound(f.project, f.project.assets.find((a) => a.type === 'song')!.id)).toBeNull();
expect(firstId).not.toBe(secondId);
});
it('plays its stretch, the sound the whole file', () => {
const { f, kick, firstId } = setup();
expect(playedSoundById(f.project, kick().id)).toBe(FILE);
expect(playedSoundById(f.project, firstId)).toBe(`${FILE}#0.5~0.75~0.005~0.005~linear~linear`);
});
it('reads "Sound / Clip" wherever a field names it, and is offered after its sound', () => {
const { f, kick, firstId, secondId, takeClipId } = setup();
expect(assetRefLabel(f.project, firstId)).toBe('Kick / Clip 1');
expect(assetRefLabel(f.project, takeClipId)).toBe('• Take 1 / Clip 1');
expect(assetRefLabel(f.project, 'gone')).toBe('gone?');
// What a song keeps to itself is offered nowhere, nor are its clips.
expect(soundChoices(f.project)).toEqual([
{ id: kick().id, label: 'Kick' },
{ id: firstId, label: 'Kick / Clip 1' },
{ id: secondId, label: 'Kick / Clip 2' },
]);
expect(
referencedAssetId(f.project, { key: 'sound', label: 'Sound', kind: 'sound', default: '' }, firstId),
).toBe(kick().id);
});
it('is set, renamed and deleted in steps an undo takes back', () => {
const { f, kick, firstId, secondId } = setup();
f.stack.execute(new SetClipPropsCommand(kick().id, firstId, { [START_KEY]: 0.25, [END_KEY]: 0.5 }));
expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.25, [END_KEY]: 0.5 });
f.stack.undo();
expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.5, [END_KEY]: 0.75 });
f.stack.execute(new RenameClipCommand(kick().id, secondId, 'Clip 1'));
expect(clipsOf(kick())[1]!.name).toBe('Clip 1 2');
f.stack.undo();
f.stack.execute(new RemoveClipCommand(kick().id, firstId));
expect(clipsOf(kick()).map((c) => c.id)).toEqual([secondId]);
expect(playedSoundById(f.project, firstId)).toBe('');
f.stack.undo();
expect(clipsOf(kick()).map((c) => c.id)).toEqual([firstId, secondId]);
});
it('refuses a value a clip cannot hold', () => {
const { f, kick, firstId } = setup();
const before = structuredClone(clipsOf(kick()));
const set = (values: Record<string, string | number>) => () =>
f.stack.execute(new SetClipPropsCommand(kick().id, firstId, values));
expect(set({ volume: 1 })).toThrow('A clip has no "volume"');
expect(set({ [START_KEY]: -1 })).toThrow('Start (s) needs a number of at least 0');
expect(set({ [START_KEY]: Number.NaN })).toThrow('Start (s) needs a number of at least 0');
expect(set({ fadeInCurve: 'wobbly' })).toThrow('Fade in curve has no "wobbly"');
expect(() => f.stack.execute(new RenameClipCommand(kick().id, firstId, ' '))).toThrow(
'A clip needs a name',
);
expect(clipsOf(kick())).toEqual(before);
});
it('survives a round trip; a clip repeating an id the document has is dropped', () => {
const { f, kick, firstId } = setup();
expect(deserializeProject(serializeProject(f.project))).toEqual(f.project);
const raw = serializeProject(f.project) as { assets: { id: string; clips?: unknown[] }[] };
const stored = raw.assets.find((a) => a.id === kick().id)!;
stored.clips = [
...stored.clips!,
{ id: firstId, name: 'Twin', props: {} },
{ id: kick().id, name: 'Sound', props: {} },
{ id: 'odd', name: 'Odd', props: { start: 1, volume: 0.5 } },
];
const read = findAssetOf(deserializeProject(raw), kick().id, 'sound')!;
expect(clipsOf(read).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2', 'Odd']);
expect(clipsOf(read)[2]!.props).toEqual({ start: 1 });
});
it('gets a fresh id in a duplicate of its sound', () => {
const { f, kick, firstId, secondId } = setup();
const duplicate = new DuplicateAssetCommand(kick().id);
f.stack.execute(duplicate);
const copy = findAssetOf(f.project, duplicate.copyId, 'sound')!;
expect(clipsOf(copy).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']);
expect(clipsOf(copy).map((c) => c.id)).not.toContain(firstId);
expect(clipsOf(copy).map((c) => c.id)).not.toContain(secondId);
expect(findSound(f.project, firstId)?.asset.id).toBe(kick().id);
});
});
describe('a clip in a song (#357)', () => {
it('is played by its item, through the FX of its sound', () => {
const { f, song, takeClipId } = setup();
const take = findSound(f.project, takeClipId)!.asset;
take.props.fx = 'some-fx';
expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeClipId]);
expect(sequenceNeeds(f.project, songSequence(f.project, song())).files).toEqual([
`${FILE}#2~3~0.005~0.005~linear~linear`,
]);
expect(itemFx(f.project, song().items[0]!)).toBe('some-fx');
});
it('keeps its take, and a second item on a clip of it gets a copy with its own clip ids', () => {
const { f, song, songId, track, takeId, takeClipId } = setup();
expect(embeddedUsedBy(f.project, song().items).map((a) => a.id)).toEqual([takeId]);
const [item] = song().items;
f.stack.execute(new SetSongCommand(songId, { items: [item!, { ...item!, id: 'second', tick: BAR }] }));
const named = song().items[1]!.asset;
const copy = findSound(f.project, named)!;
expect(copy.asset.id).not.toBe(takeId);
expect(copy.asset.owner).toBe(songId);
expect(copy.clip?.name).toBe('Clip 1');
expect(named).not.toBe(takeClipId);
expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId);
// The take nothing else names stays.
expect(song().items[0]!.asset).toBe(takeClipId);
expect(song().items.map((i) => i.track)).toEqual([track, track]);
});
it('is copied along when an item is given a copy of the sound or the song is duplicated', () => {
const { f, song, kick, secondId, takeClipId } = setup();
const { copy, ids } = embeddedCopy(kick(), song());
const copied = clipsOf(copy as SoundAsset);
expect(ids.get(secondId)).toBe(copied[1]!.id);
expect(ids.get(kick().id)).toBe(copy.id);
const duplicate = new DuplicateAssetCommand(song().id);
f.stack.execute(duplicate);
const twin = findAssetOf(f.project, duplicate.copyId, 'song')!;
const named = twin.items[0]!.asset;
expect(named).not.toBe(takeClipId);
const found = findSound(f.project, named)!;
expect([found.asset.owner, found.clip?.name]).toEqual([twin.id, 'Clip 1']);
expect(findAsset(f.project, found.asset.id)).toBe(found.asset);
});
});
describe('the strip of the Sound tab (#357)', () => {
it('picks the band under the pointer, the one drawn last where they lie over each other', () => {
const view = { panX: 0, pxPerSecond: 100 };
const bands = [
{ id: 'a', span: { start: 0, end: 1 } },
{ id: 'b', span: { start: 0.5, end: 2 } },
];
expect([10, 60, 150, 250].map((x) => clipAt(view, bands, x))).toEqual(['a', 'b', 'b', null]);
});
});
+25 -18
View File
@@ -9,6 +9,7 @@ import {
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { AddClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands';
import type { BehaviorAsset, SoundAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
@@ -25,6 +26,7 @@ import { assetRefChoices } from '../../src/core/properties/assetRef';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { playedSoundById, playedSoundOf } from '../../src/core/properties/playedSound';
import {
CLIP_ROWS,
cutOf,
END_KEY,
FADE_IN_CURVE_KEY,
@@ -128,9 +130,9 @@ describe('the sound asset', () => {
expect(asset).toMatchObject({ type: 'sound', file: '' });
expect(volumeOf(asset.props)).toBe(1);
expect(loopsOf(asset.props)).toBe(false);
expect(schemaFor('sound').map((def) => def.key)).toEqual([
VOLUME_KEY,
LOOP_KEY,
// The cut belongs to a clip of the sound (#357), the sound is the whole file.
expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx', BUS_KEY]);
expect(CLIP_ROWS.map((def) => def.key)).toEqual([
START_KEY,
END_KEY,
REVERSE_KEY,
@@ -138,10 +140,8 @@ describe('the sound asset', () => {
FADE_IN_CURVE_KEY,
FADE_OUT_KEY,
FADE_OUT_CURVE_KEY,
'fx',
BUS_KEY,
]);
expect(cutOf(asset.props)).toEqual({
expect(cutOf({})).toEqual({
start: 0,
end: 0,
fadeIn: 0.005,
@@ -293,17 +293,23 @@ describe('the cut of a sound (#343)', () => {
expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]);
});
it('is what every way of playing the asset is given, its fades in milliseconds on the asset', () => {
it('is what every way of playing a clip is given, its fades in milliseconds on the clip (#357)', () => {
const f = playingScene();
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, START_KEY, 0.5));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_IN_KEY, 25));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_KEY, 40));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_CURVE_KEY, 'equalPower'));
expect(playedSoundOf({ asset: f.sound, clip: null })).toBe(FILE);
const add = new AddClipCommand(f.sound.id, { [START_KEY]: 0.5, [FADE_IN_KEY]: 25 });
f.stack.execute(add);
f.stack.execute(
new SetClipPropsCommand(f.sound.id, add.clipId, {
[FADE_OUT_KEY]: 40,
[FADE_OUT_CURVE_KEY]: 'equalPower',
}),
);
const name = `${FILE}#0.5~0~0.025~0.04~linear~equalPower`;
expect(playedSoundOf(f.project, f.sound)).toBe(name);
expect(playedSoundById(f.project, f.sound.id)).toBe(name);
expect(playedSoundById(f.project, add.clipId)).toBe(name);
// The sound itself stays the whole file.
expect(playedSoundById(f.project, f.sound.id)).toBe(FILE);
expect(playedSoundById(f.project, '')).toBe('');
f.play.params.sound = add.clipId;
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played).toMatchObject([{ file: name }]);
@@ -333,12 +339,13 @@ describe('the cut of a sound (#343)', () => {
expect(Array.from(ramp)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
const f = playingScene();
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, true));
const add = new AddClipCommand(f.sound.id, { [REVERSE_KEY]: true });
f.stack.execute(add);
const name = `${FILE}#0~0~0.005~0.005~linear~linear~reverse`;
expect(playedSoundOf(f.project, f.sound)).toBe(name);
expect(playedSoundById(f.project, add.clipId)).toBe(name);
expect(parseCutName(name).cut).toMatchObject({ start: 0, end: 0, reverse: true });
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, false));
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
f.stack.execute(new SetClipPropsCommand(f.sound.id, add.clipId, { [REVERSE_KEY]: false }));
expect(playedSoundById(f.project, add.clipId)).toBe(FILE);
});
});
+131 -57
View File
@@ -1912,25 +1912,36 @@ export async function sound(t) {
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave between two handles. Dragging the end
// handle cuts the sound there, a drag beside the handles spans a new stretch, and that
// stretch is what plays: a name of its own, its samples cut to the sample and ramped from
// silence at an edge inside the file. One drag is one undo step.
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its
// clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a
// click picks one, and the picked clip stands between two handles. A clip is played by a
// name of its own, its samples cut to the sample and ramped from silence at an edge inside
// the file. One drag is one undo step.
const droneId = both.find((a) => a.file === drone.file)?.id;
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const tab = () => json('__gm.soundTab.span()');
const clips = () => json('__gm.soundTab.clips()');
const picked = () => json('__gm.soundTab.pickedClip()');
await waitFor(tab, (span) => span !== null);
const tabOpened = {
current: await json('__gm.soundTab.current()'),
span: await tab(),
info: await json('__gm.soundTab.info()'),
clips: await clips(),
picked: await picked(),
reverse: await json('__gm.soundTab.reverseButton()'),
played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`),
};
check(
'opening a sound shows its tab: the whole file between the handles',
'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade',
tabOpened.current === droneId &&
tabOpened.span?.start === 0 &&
Math.abs(tabOpened.span?.end - 0.75) < 0.001 &&
tabOpened.info === '0.75 s',
tabOpened.info === '0.75 s' &&
tabOpened.clips.length === 0 &&
tabOpened.picked === null &&
tabOpened.reverse.enabled === false &&
tabOpened.played === drone.file,
JSON.stringify(tabOpened),
);
// 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room
@@ -1969,51 +1980,74 @@ export async function sound(t) {
Math.abs(refitted.top - fullTop) < 0.5,
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
);
const droneProps = async () => (await assetsOfType('sound')).find((a) => a.id === droneId)?.props;
let dragFrom = await json('__gm.soundTab.at(0.75)');
let dragTo = await json('__gm.soundTab.at(0.5)');
const clipProps = async () => (await clips())[0]?.props;
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
let dragTo = await json('__gm.soundTab.stripAt(0.3)');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') };
const clipId = spanned.clips[0]?.id;
check(
'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file',
spanned.clips.length === 1 &&
spanned.clips[0].name === 'Clip 1' &&
Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 &&
Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 &&
spanned.picked === clipId &&
spanned.info ===
`${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` &&
(await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file,
JSON.stringify(spanned),
);
await press('z', { ctrl: true });
const undoneClip = { clips: await clips(), picked: await picked() };
await press('z', { ctrl: true, shift: true });
const redoneClip = await clips();
const beside = await json('__gm.soundTab.stripAt(0.5)');
await click(beside.x, beside.y);
const pickedBeside = await picked();
const onBand = await json('__gm.soundTab.stripAt(0.2)');
await click(onBand.x, onBand.y);
check(
'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound',
undoneClip.clips.length === 0 &&
undoneClip.picked === null &&
redoneClip.map((c) => c.id).join() === clipId &&
pickedBeside === null &&
(await picked()) === clipId,
JSON.stringify({ undoneClip, redoneClip, pickedBeside, picked: await picked() }),
);
dragFrom = await json('__gm.soundTab.at(0.3)');
dragTo = await json('__gm.soundTab.at(0.5)');
await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y);
await t.mouse('mouseMoved', dragFrom.x, dragFrom.y);
const onHandle = await json('__gm.soundTab.cursor()');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const shortened = { props: await droneProps(), info: await json('__gm.soundTab.info()') };
check(
'dragging the end handle cuts the sound there, the handle showing it can be dragged',
onHandle === 'ew-resize' &&
shortened.props?.start === 0 &&
Math.abs(shortened.props?.end - 0.5) < 0.01 &&
shortened.info === `${shortened.props?.end.toFixed(2)} s of 0.75 s`,
JSON.stringify({ onHandle, ...shortened }),
);
const lengthened = await clipProps();
await press('z', { ctrl: true });
const undoneCut = await droneProps();
await press('z', { ctrl: true, shift: true });
check(
'one undo puts the whole file back, a redo the cut',
(undoneCut?.end ?? 0) === 0 && Math.abs((await droneProps())?.end - 0.5) < 0.01,
JSON.stringify({ undoneCut, redone: await droneProps() }),
'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged',
onHandle === 'ew-resize' &&
Math.abs(lengthened?.start - 0.1) < 0.01 &&
Math.abs(lengthened?.end - 0.5) < 0.01 &&
Math.abs((await clipProps())?.end - 0.3) < 0.01,
JSON.stringify({ onHandle, lengthened, undone: await clipProps() }),
);
dragFrom = await json('__gm.soundTab.at(0.1)');
dragTo = await json('__gm.soundTab.at(0.3)');
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
const spanned = await droneProps();
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const clipCut = await clipProps();
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`);
const keptSamples = spanned ? Math.round((spanned.end - spanned.start) * (cutSamples?.rate ?? 0)) : -1;
const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1;
check(
'a drag beside the handles spans a new stretch, played by a name of its own, cut to the sample',
Math.abs(spanned?.start - 0.1) < 0.01 &&
Math.abs(spanned?.end - 0.3) < 0.01 &&
cutPlayed === `${drone.file}#${spanned?.start}~${spanned?.end}~0.005~0.005~linear~linear` &&
'the clip is played by a name of its own, cut to the sample',
cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` &&
cutSamples?.length === keptSamples,
JSON.stringify({ spanned, cutPlayed, cutSamples, keptSamples }),
JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }),
);
check(
'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file',
cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01,
JSON.stringify(cutSamples),
);
// 34h-ii. Each edge has a fade of its own, each shaped by its own curve (#345).
// 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345).
const tabFadeOut = await json('__gm.soundTab.fadeOutField()');
await click(tabFadeOut.x, tabFadeOut.y);
await t.typeText('40');
@@ -2023,17 +2057,17 @@ export async function sound(t) {
await click(curveOutButton.x, curveOutButton.y);
await clickText('Exponential');
await sleep(150);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`);
check(
'the header sets the fade out and its curve on their own, the fade in staying Linear',
(await droneProps())?.fadeOut === 40 &&
(await droneProps())?.fadeOutCurve === 'exponential' &&
((await droneProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await clipProps())?.fadeOut === 40 &&
(await clipProps())?.fadeOutCurve === 'exponential' &&
((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' &&
(await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' &&
curved.endsWith('~0.005~0.04~linear~exponential'),
JSON.stringify({ props: await droneProps(), curved }),
JSON.stringify({ props: await clipProps(), curved }),
);
await press('z', { ctrl: true });
await press('z', { ctrl: true });
@@ -2046,14 +2080,14 @@ export async function sound(t) {
await t.typeText('0');
await press('Enter');
await sleep(150);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
check(
'after two undos the curve is Linear again, and both fades go to 0 in the header',
(await droneProps())?.fadeIn === 0 &&
(await droneProps())?.fadeOut === 0 &&
((await droneProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
(await clipProps())?.fadeIn === 0 &&
(await clipProps())?.fadeOut === 0 &&
((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
unfaded.endsWith('~0~0~linear~linear'),
JSON.stringify({ props: await droneProps(), unfaded }),
JSON.stringify({ props: await clipProps(), unfaded }),
);
// The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the
// exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp.
@@ -2083,7 +2117,7 @@ export async function sound(t) {
(on) => on === false,
);
check(
'Play plays only the stretch, its playhead running inside it, and stops when it ends',
'Play plays only the picked clip, its playhead running inside it, and stops when it ends',
tabHeard.last?.file === unfaded &&
Math.abs(tabHeard.last?.duration - 0.2) < 0.01 &&
tabPlayhead !== null &&
@@ -2093,25 +2127,32 @@ export async function sound(t) {
(await json('__gm.soundTab.playhead()')) === null,
JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }),
);
// 34h-iii. Reverse plays the same stretch backwards, the playhead running from its end (#321).
// 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321).
const reverseButton = await json('__gm.soundTab.reverseButton()');
await click(reverseButton.x, reverseButton.y);
await sleep(150);
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`);
check(
'Reverse turns the stretch around: its first samples are the last of the stretch played forwards',
(await droneProps())?.reverse === true &&
'Reverse turns the clip around: its first samples are the last of the clip played forwards',
(await clipProps())?.reverse === true &&
(await json('__gm.soundTab.reverseButton()')).on === true &&
reversed === `${unfaded}~reverse` &&
reversedSamples?.length === rawSamples?.length &&
reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)),
JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }),
);
// The whole file (0.75 s) gives the playhead time for several readings.
const tabWhole = await json('__gm.soundTab.wholeButton()');
await click(tabWhole.x, tabWhole.y);
await sleep(150);
// Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged
// past both ends of it.
for (const [from, to] of [
[0.1, -0.1],
[0.3, 0.9],
]) {
const a = await json(`__gm.soundTab.at(${from})`);
const b = await json(`__gm.soundTab.at(${to})`);
await t.drag(a.x, a.y, b.x, b.y);
}
const whole = await clipProps();
await click(tabPlay.x, tabPlay.y);
await waitFor(
() => json('__gm.soundTab.playhead()'),
@@ -2125,15 +2166,48 @@ export async function sound(t) {
)
).filter((seconds) => seconds !== null);
check(
'played backwards, the playhead runs from the end of the file towards its start',
(await droneProps())?.reverse === true &&
'played backwards, the playhead runs from the end of the clip towards its start',
whole?.reverse === true &&
whole?.start === 0 &&
whole?.end === 0 &&
backwards.length >= 2 &&
backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) &&
backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) &&
backwards.at(-1) < backwards[0],
JSON.stringify(backwards),
JSON.stringify({ whole, backwards }),
);
await shot('34-sound-tab');
// 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1".
await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' });
// The row is named after the port, as the node body names it.
const soundRow = await json("__gm.inspectorField('sound', true)");
await click(soundRow.x, soundRow.y);
const soundOffered = await json('__gm.overlayTexts()');
await clickText('drone / Clip 1');
await sleep(150);
const playNode = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find(
(n) => n.id === 'sn-play',
);
check(
'a sound field offers each clip after its sound, and picking one names the clip',
soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 &&
(await playNode())?.params.sound === clipId,
JSON.stringify({ soundOffered, node: await playNode() }),
);
// 34h-v. Delete takes the picked clip away, and the sound stays.
await evaluate(
`__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`,
);
await waitFor(picked, (id) => id === clipId);
await press('Delete');
check(
'Delete takes the picked clip away, the sound staying on screen',
(await clips()).length === 0 &&
(await picked()) === null &&
(await json('__gm.soundTab.current()')) === droneId,
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
);
// 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the