Cut a sound into named clips, as a texture into frames (#357)
A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.
- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
generator with asset ids, so a sound field holds a sound or a clip,
as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
Volume, loop, sound FX and bus stay on the sound for all its clips.
The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
instrument, a Reverb impulse, song items, the scheduler and the test
sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
"Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
A drag there makes a clip, a click picks one, beside the bands the
sound. Only the picked clip has handles. Reverse, fades and curves
in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
inspector shows its name, its cut rows and Delete clip. Delete
takes it away without asking, as with a frame. Ctrl+Z brings it
back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
duplicated with its takes, and the copy a split or paste makes of
an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
the clip picked. The Sound row of an item on an embedded take
offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
user decided.
- No migration in the code, as the user decided. The one project
with cut sounds was changed over by hand.
Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".
Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7fa1d15b0
commit
42a3759d9d
32 files changed
+1230
-363
No files matched your search
@@ -18,7 +18,7 @@ import { heldSongGraph } from '../music/routing';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { isSoundFile, renewClipIds } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs';
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
@@ -192,7 +192,13 @@ export class DuplicateAssetCommand implements Command {
|
||||
|
||||
/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */
|
||||
function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] {
|
||||
const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()]));
|
||||
const ids = new Map<string, string>();
|
||||
const copies = embeddedIn(project, from).map((a) => {
|
||||
const copy = { ...structuredClone(a), id: newId(), owner: song.id };
|
||||
ids.set(a.id, copy.id);
|
||||
renewClipIds(copy, ids);
|
||||
return copy;
|
||||
});
|
||||
const renamed = (id: string) => ids.get(id) ?? id;
|
||||
for (const track of song.tracks) track.fx = renamed(track.fx);
|
||||
for (const item of song.items) {
|
||||
@@ -201,7 +207,7 @@ function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[]
|
||||
}
|
||||
const fx = song.props[ASSET_FX_KEY];
|
||||
if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx);
|
||||
return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id }));
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** A copy is a project asset of its own, even one of something a song kept to itself. */
|
||||
@@ -210,6 +216,7 @@ function copyOf(original: Asset, id: string, name: string): Asset {
|
||||
delete copy.owner;
|
||||
if (copy.type === 'texture' || copy.type === 'pixelart')
|
||||
for (const frame of copy.frames) frame.id = newId();
|
||||
renewClipIds(copy, new Map());
|
||||
if (copy.type === 'scene' || copy.type === 'container') {
|
||||
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
|
||||
for (const object of copy.objects) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/** The clips of a sound (#357): stretches of its file with a name, as frames are of a texture. */
|
||||
import { newId } from '../ids';
|
||||
import type { SoundAsset, SoundClip } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { clipsOf, nextClipName, uniqueClipName } from '../model/sound';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
function requireSound(project: Project, assetId: string): SoundAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'sound') throw new CommandError(`${asset.name} is not a sound`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function requireClip(asset: SoundAsset, clipId: string): SoundClip {
|
||||
const clip = clipsOf(asset).find((c) => c.id === clipId);
|
||||
if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** What a clip may hold: a row of `CLIP_ROWS`, a finite number at least 0, a curve it offers. */
|
||||
function heldClipValue(key: string, value: PropertyValue): PropertyValue {
|
||||
const def = CLIP_ROWS.find((row) => row.key === key);
|
||||
if (!def) throw new CommandError(`A clip has no "${key}"`);
|
||||
if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
|
||||
throw new CommandError(`${def.label} needs a number of at least 0`);
|
||||
if (def.kind === 'boolean' && typeof value !== 'boolean')
|
||||
throw new CommandError(`${def.label} is on or off`);
|
||||
if (def.kind === 'enum' && !def.options.some((option) => option.value === value))
|
||||
throw new CommandError(`${def.label} has no ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `clipId` is known before execution, so the caller can select the new clip right after executing. */
|
||||
export class AddClipCommand implements Command {
|
||||
readonly label = 'Add clip';
|
||||
readonly clipId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly props: PropertyBag,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const props: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(key, value);
|
||||
// Named per execution: a redo has to fit the names that exist when it runs.
|
||||
asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some of a clip's values in one step – a drag sets its start and its end together. The values to
|
||||
* revert to are read at execution time.
|
||||
*/
|
||||
export class SetClipPropsCommand implements Command {
|
||||
private previous: [string, PropertyValue | undefined][] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly values: PropertyBag,
|
||||
readonly label = 'Set clip',
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
const held = Object.entries(this.values).map(([key, value]) => [key, heldClipValue(key, value)] as const);
|
||||
this.previous = held.map(([key]) => [key, clip.props[key]]);
|
||||
for (const [key, value] of held) clip.props[key] = value;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
for (const [key, value] of this.previous)
|
||||
if (value === undefined) delete clip.props[key];
|
||||
else clip.props[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameClipCommand implements Command {
|
||||
readonly label = 'Rename clip';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clip = requireClip(asset, this.clipId);
|
||||
const name = this.name.trim();
|
||||
if (!name) throw new CommandError('A clip needs a name');
|
||||
this.previous = clip.name;
|
||||
clip.name = uniqueClipName(asset, name, this.clipId);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireClip(requireSound(project, this.assetId), this.clipId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo puts it back where it was. What plays it is left pointing at it, as a sprite is at a deleted
|
||||
* frame: the reference reads as missing and comes back by itself on undo.
|
||||
*/
|
||||
export class RemoveClipCommand implements Command {
|
||||
readonly label = 'Delete clip';
|
||||
private removed?: Removed<SoundClip>;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly clipId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
const removed = removeFirst(clips, (c) => c.id === this.clipId);
|
||||
if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`);
|
||||
this.removed = removed;
|
||||
asset.clips = clips;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
restoreAt(clips, [this.removed]);
|
||||
asset.clips = clips;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { InstrumentAsset } from '../model/Asset';
|
||||
import { graphLookup } from '../model/graphLookup';
|
||||
import { textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { playedSoundOf } from '../properties/playedSound';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { volumeOf } from '../properties/sounds';
|
||||
@@ -77,19 +78,18 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
|
||||
return {
|
||||
lookup: graphLookup(project),
|
||||
sound: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'sound');
|
||||
if (!asset) return null;
|
||||
const ref = findSound(project, assetId);
|
||||
if (!ref) return null;
|
||||
// An asset without a file stays without one, so the Sample can say so.
|
||||
const file = asset.file ? playedSoundOf(project, asset) : '';
|
||||
return { file, volume: volumeOf(resolveAssetProps(project, asset)) };
|
||||
return { file: playedSoundOf(ref), volume: volumeOf(resolveAssetProps(project, ref.asset)) };
|
||||
},
|
||||
soundFx: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'soundfx');
|
||||
return asset ? soundFxBuildFor(asset, project).compiled : null;
|
||||
},
|
||||
key: (assetId) => {
|
||||
const sound = findAssetOf(project, assetId, 'sound');
|
||||
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
|
||||
const sound = findSound(project, assetId);
|
||||
if (sound) return `${playedSoundOf(sound)}@${volumeOf(resolveAssetProps(project, sound.asset))}`;
|
||||
const fx = findAssetOf(project, assetId, 'soundfx');
|
||||
return fx ? soundFxKey(fx, project) : '';
|
||||
},
|
||||
|
||||
+16
-1
@@ -308,11 +308,26 @@ export interface FontAsset extends AssetBase {
|
||||
/**
|
||||
* `file` names the sound in `data/sounds/` the way a font names its typeface: content-addressed
|
||||
* `{folder}/{sha256}.{ext}`, empty when the upload failed. Nothing wears a sound – a graph plays it with
|
||||
* `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`).
|
||||
* `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`). The
|
||||
* sound itself is the whole file; its clips are stretches of it (#357).
|
||||
*/
|
||||
export interface SoundAsset extends AssetBase {
|
||||
type: 'sound';
|
||||
file: string;
|
||||
/** Absent for a sound without clips. */
|
||||
clips?: SoundClip[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A stretch of a sound's file with a name (#357), as a frame is a cut-out of a texture. `id` shares
|
||||
* the generator with asset ids, so a sound field holds either. `props` are the cut's: start, end,
|
||||
* fades, their curves and reverse (see `properties/sounds.ts`); volume, loop, bus and FX stay the
|
||||
* sound's.
|
||||
*/
|
||||
export interface SoundClip {
|
||||
id: string;
|
||||
name: string;
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-3
@@ -1,3 +1,6 @@
|
||||
import { nextFreeName, nextNumberedName } from '../commands/naming';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, SoundAsset, SoundClip } from './Asset';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { storedFilePattern } from './storedFile';
|
||||
|
||||
@@ -7,11 +10,85 @@ export function isSoundFile(name: string): boolean {
|
||||
return FILE_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export const CLIP_SEPARATOR = ' / ';
|
||||
export const DEFAULT_CLIP_NAME = 'Clip';
|
||||
|
||||
export function clipsOf(asset: SoundAsset): readonly SoundClip[] {
|
||||
return asset.clips ?? [];
|
||||
}
|
||||
|
||||
/** What a sound field names (#357): a sound, the whole file, or one of its clips. */
|
||||
export interface SoundRef {
|
||||
asset: SoundAsset;
|
||||
clip: SoundClip | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip ids share the generator with asset ids and are unique across the document, which lets a
|
||||
* sound field hold either – as a texture field holds a texture or one of its frames.
|
||||
*/
|
||||
export function findSound(project: Project, id: string): SoundRef | null {
|
||||
if (id === '') return null;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset) return asset.type === 'sound' ? { asset, clip: null } : null;
|
||||
for (const sound of project.assets) {
|
||||
if (sound.type !== 'sound') continue;
|
||||
const clip = clipsOf(sound).find((c) => c.id === id);
|
||||
if (clip) return { asset: sound, clip };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The asset among `assets` an id names: the asset itself, or the sound one of whose clips it is. */
|
||||
export function assetNamedBy(assets: readonly Asset[], id: string): Asset | undefined {
|
||||
if (id === '') return undefined;
|
||||
return assets.find((a) => a.id === id || (a.type === 'sound' && clipsOf(a).some((c) => c.id === id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes
|
||||
* each old id with its new one in `ids` so the places that named a clip can follow.
|
||||
*/
|
||||
export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
|
||||
if (copy.type !== 'sound') return;
|
||||
for (const clip of clipsOf(copy)) {
|
||||
const id = newId();
|
||||
ids.set(clip.id, id);
|
||||
clip.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every id that means "this sound": the sound itself and each of its clips. */
|
||||
export function soundRefIds(asset: SoundAsset): string[] {
|
||||
return [asset.id, ...clipsOf(asset).map((c) => c.id)];
|
||||
}
|
||||
|
||||
/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */
|
||||
export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string {
|
||||
return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName;
|
||||
}
|
||||
|
||||
/** "Clip 1", 2, 3 …: the first number none of the sound's clips has. */
|
||||
export function nextClipName(asset: SoundAsset): string {
|
||||
return nextNumberedName(
|
||||
clipsOf(asset).map((c) => c.name),
|
||||
DEFAULT_CLIP_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
/** A name free among the sound's clips: `name`, else "name 2", "name 3" … */
|
||||
export function uniqueClipName(asset: SoundAsset, name: string, ignoreId = ''): string {
|
||||
return nextFreeName(
|
||||
clipsOf(asset)
|
||||
.filter((c) => c.id !== ignoreId)
|
||||
.map((c) => c.name),
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
/** The file a `sound` value points at, empty when the id is unset or no longer resolves. */
|
||||
export function soundFileOf(project: Project, id: string): string {
|
||||
if (id === '') return '';
|
||||
const asset = findAsset(project, id);
|
||||
return asset?.type === 'sound' ? asset.file : '';
|
||||
return findSound(project, id)?.asset.file ?? '';
|
||||
}
|
||||
|
||||
/** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Asset, PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { assetNamedBy, renewClipIds } from '../model/sound';
|
||||
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
|
||||
import { nextNumberedName } from '../commands/naming';
|
||||
import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
|
||||
@@ -91,9 +92,15 @@ function ownedBy<T extends Asset>(asset: T, song: SongAsset): T {
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. */
|
||||
export function embeddedCopy(asset: Asset, song: SongAsset): Asset {
|
||||
return ownedBy({ ...structuredClone(asset), id: newId() }, song);
|
||||
/**
|
||||
* A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest.
|
||||
* `ids` says what the asset and its clips (#357) are called in the copy.
|
||||
*/
|
||||
export function embeddedCopy(asset: Asset, song: SongAsset): { copy: Asset; ids: Map<string, string> } {
|
||||
const copy = ownedBy({ ...structuredClone(asset), id: newId() }, song);
|
||||
const ids = new Map([[asset.id, copy.id]]);
|
||||
renewClipIds(copy, ids);
|
||||
return { copy, ids };
|
||||
}
|
||||
|
||||
/** The patterns that follow `key` of an asset: those a song keeps, when the key is part of its clock. */
|
||||
@@ -164,8 +171,10 @@ function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem
|
||||
|
||||
/** The embedded assets the places of these items name, wherever they belong: what a copy of them takes along. */
|
||||
export function embeddedUsedBy(project: Project, items: readonly SongItem[]): Asset[] {
|
||||
const ids = new Set(items.flatMap((i) => [i.asset, i.fx]));
|
||||
return project.assets.filter((a) => isEmbedded(a) && ids.has(a.id));
|
||||
const named = new Set(
|
||||
items.flatMap((i) => [i.asset, i.fx]).map((id) => assetNamedBy(project.assets, id)?.id),
|
||||
);
|
||||
return project.assets.filter((a) => isEmbedded(a) && named.has(a.id));
|
||||
}
|
||||
|
||||
export interface Settled {
|
||||
@@ -194,20 +203,24 @@ export function settleEmbedded(
|
||||
const own = { ...song.props };
|
||||
slotsOf(own, tracks, items).forEach((slot, index) => {
|
||||
if (slot.id === '' || slot.id === NO_FX) return;
|
||||
const found = findAsset(project, slot.id) ?? added.find((a) => a.id === slot.id);
|
||||
const source = found ?? spare.find((a) => a.id === slot.id);
|
||||
// A place may name a clip of a sound (#357): the sound is what it claims.
|
||||
const found = assetNamedBy(project.assets, slot.id) ?? assetNamedBy(added, slot.id);
|
||||
const source = found ?? assetNamedBy(spare, slot.id);
|
||||
if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return;
|
||||
if (found?.owner === song.id && !claimed.has(found.id)) {
|
||||
claimed.add(found.id);
|
||||
return;
|
||||
}
|
||||
if (index === 0) return;
|
||||
const id = found || taken.has(source.id) ? newId() : source.id;
|
||||
const fresh = found !== undefined || taken.has(source.id);
|
||||
const id = fresh ? newId() : source.id;
|
||||
const copy = ownedBy({ ...structuredClone(source), id }, song);
|
||||
const ids = new Map([[source.id, id]]);
|
||||
if (fresh) renewClipIds(copy, ids);
|
||||
taken.add(id);
|
||||
added.push(copy);
|
||||
claimed.add(id);
|
||||
slot.set(id);
|
||||
slot.set(ids.get(slot.id) ?? id);
|
||||
});
|
||||
const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id);
|
||||
return { added, dropped };
|
||||
|
||||
+12
-5
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { pickedFx } from '../properties/assetFx';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
@@ -148,8 +149,14 @@ export function dbToGain(db: number): number {
|
||||
|
||||
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
|
||||
export function itemFx(project: Project, item: SongItem): string {
|
||||
const played = findAsset(project, item.asset);
|
||||
return pickedFx(item.fx, played?.type === item.kind ? played.props : {});
|
||||
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
|
||||
}
|
||||
|
||||
/** The asset an item plays: its pattern, or its sound – also when it plays a clip of it (#357). */
|
||||
export function itemAsset(project: Project, item: SongItem): PatternAsset | SoundAsset | undefined {
|
||||
return item.kind === 'sound'
|
||||
? findSound(project, item.asset)?.asset
|
||||
: findAssetOf(project, item.asset, 'pattern');
|
||||
}
|
||||
|
||||
/** Whether a pattern item is heard on a bus of its own (#346): it goes through a sound FX that is there. */
|
||||
@@ -207,7 +214,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||
const volume = dbToGain(item.volume);
|
||||
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track);
|
||||
if (item.kind === 'sound') {
|
||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||
if (!findSound(project, item.asset)?.asset.file) continue;
|
||||
clips.push({
|
||||
tick: item.tick,
|
||||
length: item.length,
|
||||
|
||||
@@ -103,20 +103,7 @@ import {
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||||
import { FADE_CURVES } from '../model/sound';
|
||||
import {
|
||||
DEFAULT_FADE_CURVE,
|
||||
DEFAULT_FADE_MS,
|
||||
END_KEY as SOUND_END_KEY,
|
||||
FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY,
|
||||
FADE_IN_KEY as SOUND_FADE_IN_KEY,
|
||||
FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY,
|
||||
FADE_OUT_KEY as SOUND_FADE_OUT_KEY,
|
||||
LOOP_KEY as SOUND_LOOP_KEY,
|
||||
REVERSE_KEY as SOUND_REVERSE_KEY,
|
||||
START_KEY as SOUND_START_KEY,
|
||||
VOLUME_KEY as SOUND_VOLUME_KEY,
|
||||
} from './sounds';
|
||||
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
||||
import {
|
||||
BEAT_OPTIONS,
|
||||
DEFAULT_BARS,
|
||||
@@ -717,39 +704,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
font: [filterRow(true)],
|
||||
// The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and
|
||||
// a `Play sound` node scales the one and can add to the other.
|
||||
// The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`).
|
||||
sound: [
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
|
||||
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
// That stretch backwards (#321), its fade in at the end it starts from.
|
||||
{ key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
|
||||
// The ramps at a start and an end inside the file, each with its shape (#345).
|
||||
...(
|
||||
[
|
||||
[SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'],
|
||||
[SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'],
|
||||
] as const
|
||||
).flatMap(([key, label, curveKey, curveLabel]) => [
|
||||
{
|
||||
key,
|
||||
label,
|
||||
kind: 'number' as const,
|
||||
default: DEFAULT_FADE_MS,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: curveKey,
|
||||
label: curveLabel,
|
||||
kind: 'enum' as const,
|
||||
default: DEFAULT_FADE_CURVE,
|
||||
options: FADE_CURVES,
|
||||
},
|
||||
]),
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { Asset, SoundAsset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { clipsOf, findSound, soundRefName } from '../model/sound';
|
||||
import { listedAssets, markedName } from '../music/embedded';
|
||||
import { NO_FX } from './assetFx';
|
||||
import { MASTER_BUS } from './buses';
|
||||
@@ -17,7 +18,26 @@ export function assetRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '' || id === NO_FX) return NO_ASSET_LABEL;
|
||||
if (id === MASTER_BUS) return MASTER_LABEL;
|
||||
const asset = project ? findAsset(project, id) : undefined;
|
||||
return asset ? markedName(asset) : `${id}?`;
|
||||
if (asset) return markedName(asset);
|
||||
// A clip of a sound (#357) reads "Sound / Clip", as a frame does.
|
||||
const clip = project ? findSound(project, id) : null;
|
||||
return clip ? soundRefName(clip, markedName(clip.asset)) : `${id}?`;
|
||||
}
|
||||
|
||||
/** What a sound field offers (#357): each sound, followed by its clips. */
|
||||
export function soundChoices(project: Project): { id: string; label: string }[] {
|
||||
return assetRefChoices(project, ['sound']).flatMap((asset) =>
|
||||
asset.type === 'sound' ? soundEntries(asset) : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** A sound and its clips as choices, "Kick", "Kick / Clip 1" … */
|
||||
export function soundEntries(asset: SoundAsset): { id: string; label: string }[] {
|
||||
const name = markedName(asset);
|
||||
return [
|
||||
{ id: asset.id, label: name },
|
||||
...clipsOf(asset).map((clip) => ({ id: clip.id, label: soundRefName({ asset, clip }, name) })),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { SoundAsset } from '../model/Asset';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { cutName } from '../model/sound';
|
||||
import { resolveAssetProps } from './resolve';
|
||||
import type { Project } from '../model/Project';
|
||||
import { cutName, findSound, type SoundRef } from '../model/sound';
|
||||
import { cutOf } from './sounds';
|
||||
|
||||
/**
|
||||
* What a sound asset is played by (#343): its file, or the stretch of it the asset is cut to. Every
|
||||
* way a sound is heard asks this rather than for the file – the cut is the sample.
|
||||
* What a sound is played by: the whole file for the sound itself, the stretch of it a clip is cut to
|
||||
* for one of its clips (#357). Every way a sound is heard asks this rather than for the file – the
|
||||
* cut is the sample.
|
||||
*/
|
||||
export function playedSoundOf(project: Project, asset: SoundAsset): string {
|
||||
return cutName(asset.file, cutOf(resolveAssetProps(project, asset)));
|
||||
export function playedSoundOf(ref: SoundRef): string {
|
||||
return ref.clip ? cutName(ref.asset.file, cutOf(ref.clip.props)) : ref.asset.file;
|
||||
}
|
||||
|
||||
/** The same by id, empty when the id is unset or no longer names a sound. */
|
||||
/** The same by id, empty when the id is unset or names neither a sound nor a clip of one. */
|
||||
export function playedSoundById(project: Project, id: string): string {
|
||||
const asset = id === '' ? undefined : findAssetOf(project, id, 'sound');
|
||||
return asset ? playedSoundOf(project, asset) : '';
|
||||
const ref = findSound(project, id);
|
||||
return ref ? playedSoundOf(ref) : '';
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
|
||||
import { findPixelFrame } from '../model/pixelart';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { findFrame } from '../model/texture';
|
||||
import { isKindWith } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221).
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221) – and a
|
||||
* clip its sound.
|
||||
*/
|
||||
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
|
||||
if (!isKindWith('reference', def.kind)) return null;
|
||||
const id = textOf(value);
|
||||
if (id === '') return null;
|
||||
if (findAsset(project, id)) return id;
|
||||
if (def.kind !== 'texture') return null;
|
||||
// A clip (#357) names the sound it was cut from, as a frame does.
|
||||
if (def.kind !== 'texture') return findSound(project, id)?.asset.id ?? null;
|
||||
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { booleanOf, type PropertyBag } from '../model/Properties';
|
||||
import { type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
|
||||
import { FADE_CURVES, type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound';
|
||||
import { coerceAs } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */
|
||||
export const VOLUME_KEY = 'volume';
|
||||
/** Whether this sound is music: it repeats until something stops it. */
|
||||
export const LOOP_KEY = 'loop';
|
||||
/** Where in its file the sound starts and ends, in seconds; an end of 0 is the end of the file (#343). */
|
||||
/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */
|
||||
export const START_KEY = 'start';
|
||||
export const END_KEY = 'end';
|
||||
/** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */
|
||||
@@ -20,6 +21,24 @@ export const DEFAULT_FADE_CURVE: FadeCurve = 'linear';
|
||||
/** Whether the stretch plays backwards (#321). */
|
||||
export const REVERSE_KEY = 'reverse';
|
||||
|
||||
/** The rows of a clip of a sound (#357): the stretch it is, set in the Sound tab, and how it is played. */
|
||||
export const CLIP_ROWS: readonly PropertyDef[] = [
|
||||
{ key: START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
{ key: END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
// That stretch backwards (#321), its fade in at the end it starts from.
|
||||
{ key: REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
|
||||
// The ramps at a start and an end inside the file, each with its shape (#345).
|
||||
...(
|
||||
[
|
||||
[FADE_IN_KEY, 'Fade in (ms)', FADE_IN_CURVE_KEY, 'Fade in curve'],
|
||||
[FADE_OUT_KEY, 'Fade out (ms)', FADE_OUT_CURVE_KEY, 'Fade out curve'],
|
||||
] as const
|
||||
).flatMap(([key, label, curveKey, curveLabel]): PropertyDef[] => [
|
||||
{ key, label, kind: 'number', default: DEFAULT_FADE_MS, min: 0, max: 1000, step: 1, decimals: 0 },
|
||||
{ key: curveKey, label: curveLabel, kind: 'enum', default: DEFAULT_FADE_CURVE, options: FADE_CURVES },
|
||||
]),
|
||||
];
|
||||
|
||||
export function volumeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VOLUME_KEY], 'number', 1);
|
||||
}
|
||||
@@ -28,7 +47,7 @@ export function loopsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[LOOP_KEY]);
|
||||
}
|
||||
|
||||
/** The stretch of its file a sound is, its fades in seconds. */
|
||||
/** The stretch of its file a clip is, its fades in seconds. */
|
||||
export function cutOf(bag: PropertyBag): SoundCut {
|
||||
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
|
||||
const curve = (key: string) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
PixelFrame,
|
||||
PixelLayer,
|
||||
SceneAsset,
|
||||
SoundClip,
|
||||
StateMachineAsset,
|
||||
TextureFrame,
|
||||
GradientStop,
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||
|
||||
/**
|
||||
@@ -258,7 +260,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props, file });
|
||||
} else if (type === 'sound') {
|
||||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||||
assets.push({ id, type, name, props, file });
|
||||
const clips = normalizeClips(a.clips, takenIds);
|
||||
assets.push({ id, type, name, props, file, ...(clips.length > 0 && { clips }) });
|
||||
} else if (type === 'tile') {
|
||||
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
|
||||
// in the tab and comes back when the texture it named is restored by an undo.
|
||||
@@ -738,6 +741,23 @@ function normalizeTexture(
|
||||
return { file, width, height, frames };
|
||||
}
|
||||
|
||||
/** The clips of a sound (#357); like a frame's, a clip's id may not repeat one the document has. */
|
||||
function normalizeClips(raw: unknown, takenIds: Set<string>): SoundClip[] {
|
||||
const clips: SoundClip[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const c = asRecord(item);
|
||||
const id = typeof c.id === 'string' ? c.id : '';
|
||||
if (!id || takenIds.has(id)) continue;
|
||||
takenIds.add(id);
|
||||
clips.push({
|
||||
id,
|
||||
name: typeof c.name === 'string' ? c.name : '',
|
||||
props: normalizeBag(c.props, CLIP_ROWS, true),
|
||||
});
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`.
|
||||
* A picture without them is one nobody sliced, and keeps saying so.
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user