Give a song, a track or an item a sound FX of its own (#355)
A sound FX for one place in a song had to be a project asset, which then stood in every picker. Now every pick of a sound FX in a song can make one the song keeps for that place alone, on top of the embedded assets of the first two parts. - Create sound FX sits at the end of the Sound FX row of the song, a track and an item, and of the menu of a track's FX button. It makes "Sound FX 1", 2, … per song with the Input wired to the Output, picks it and opens it. That is one undo step. - Embed a copy, while the place names a project FX, gives the place a copy under the same name. - While the place names its own, the menu shows it with the mark and offers Edit sound FX and Make project asset. - Picking anything else lets it go in the same undo step. For a track or an item `settleEmbedded` did that already. For the song's own row `AssetPropertyCommand` now settles the song after `fx`. - The mark `•` moved to the core. `assetRefLabel` puts it in front of an embedded asset, so the inspector rows show it like the timeline. - `FieldHandlers.moreChoices` lets a caller end a reference menu with entries of its own. `embeddedPatternName` is `embeddedName` with a base now. Tests: - Unit own-fx (new): 6 green, 6 counter-checks each red - Unit embedded and 80 neighbouring files: 1434 green - e2e 65 (melektaus-atx): ALL PASSED, 51 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49669017b1
commit
668682c4ff
11 files changed
+339
-37
No files matched your search
@@ -42,6 +42,7 @@ import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
import { SetSongCommand } from './songCommands';
|
||||
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: initialPropsFor(type) };
|
||||
@@ -539,9 +540,13 @@ export class SetMachineStartCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. */
|
||||
/**
|
||||
* An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. A
|
||||
* song that picks another sound FX lets go of the one it kept to itself, in that step too.
|
||||
*/
|
||||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
private followed: { id: string; previous: PropertyValue | undefined }[] = [];
|
||||
private settled: SetSongCommand | null = null;
|
||||
|
||||
protected constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -559,9 +564,13 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
setBagValue(pattern.props, this.key, asset.props[this.key]);
|
||||
return { id: pattern.id, previous };
|
||||
});
|
||||
this.settled =
|
||||
asset.type === 'song' && this.key === ASSET_FX_KEY ? new SetSongCommand(asset.id, {}) : null;
|
||||
this.settled?.execute(project);
|
||||
}
|
||||
|
||||
override undo(project: Project): void {
|
||||
this.settled?.undo(project);
|
||||
for (const { id, previous } of this.followed)
|
||||
setBagValue(requireAsset(project, id).props, this.key, previous);
|
||||
super.undo(project);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* track or an item. `settleEmbedded` holds a song to that after every edit, so a split, a duplicate
|
||||
* or a paste gets a copy of its own and what nothing uses any more goes.
|
||||
*/
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
@@ -40,6 +42,13 @@ export function isEmbedded(asset: Asset): boolean {
|
||||
return (asset.owner ?? '') !== '';
|
||||
}
|
||||
|
||||
/** In front of the name of what the song keeps to itself, wherever it is named. */
|
||||
export const EMBEDDED_MARK = '•';
|
||||
|
||||
export function markedName(asset: Asset): string {
|
||||
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
|
||||
}
|
||||
|
||||
/** The assets a list offers: all but those a song keeps to itself. */
|
||||
export function listedAssets(project: Project): Asset[] {
|
||||
return project.assets.filter((a) => !isEmbedded(a));
|
||||
@@ -93,14 +102,27 @@ export function clockFollowers(project: Project, asset: Asset, key: string): Pat
|
||||
return embeddedIn(project, asset.id).filter((a): a is PatternAsset => a.type === 'pattern');
|
||||
}
|
||||
|
||||
/** "Pattern 1", 2, 3 …: the first number none of what the song keeps to itself has. */
|
||||
export function embeddedPatternName(project: Project, songId: string): string {
|
||||
/** "Pattern 1", 2, 3 … ("Sound FX 1" …): the first number none of what the song keeps to itself has. */
|
||||
export function embeddedName(project: Project, songId: string, base: string): string {
|
||||
return nextNumberedName(
|
||||
embeddedIn(project, songId).map((a) => a.name),
|
||||
'Pattern',
|
||||
base,
|
||||
);
|
||||
}
|
||||
|
||||
/** What a sound FX made in a song starts as (#355): the Input wired to the Output, so it lets the sound through. */
|
||||
export function passThroughFxGraph(): Graph {
|
||||
const input = newId();
|
||||
const output = newId();
|
||||
return {
|
||||
nodes: [
|
||||
{ id: input, type: SOUND_FX_INPUT, x: 0, y: 0, params: {} },
|
||||
{ id: output, type: SOUND_FX_OUTPUT, x: 260, y: 0, params: {} },
|
||||
],
|
||||
edges: [{ id: newId(), from: { node: input, port: 'out' }, to: { node: output, port: 'in' } }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument a pattern made at `tick` on a track is played with: that of the pattern item there
|
||||
* that starts last before it, else of the first one after; '' on a track without one.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { listedAssets } from '../music/embedded';
|
||||
import { listedAssets, markedName } from '../music/embedded';
|
||||
import { NO_FX } from './assetFx';
|
||||
import { MASTER_BUS } from './buses';
|
||||
|
||||
@@ -9,13 +9,15 @@ export const NO_ASSET_LABEL = 'None';
|
||||
export const MASTER_LABEL = 'Master';
|
||||
|
||||
/**
|
||||
* The asset's name, "None" for the empty id, `<id>?` when it no longer resolves – a dangling
|
||||
* reference shows its id rather than passing for an empty field.
|
||||
* The asset's name, marked for one a song keeps to itself (#355), "None" for the empty id, `<id>?`
|
||||
* when it no longer resolves – a dangling reference shows its id rather than passing for an empty
|
||||
* field.
|
||||
*/
|
||||
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;
|
||||
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
|
||||
const asset = project ? findAsset(project, id) : undefined;
|
||||
return asset ? markedName(asset) : `${id}?`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { isRowHiddenWhenEmbedded } from '../../core/music/embedded';
|
||||
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
|
||||
import { ASSET_FX_KEY } from '../../core/properties/assetFx';
|
||||
import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
@@ -59,6 +60,7 @@ import { FontPreview } from '../font/FontPreview';
|
||||
import { clearSaved, hasSaved } from '../saves';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
import type { VariablesOwner } from '../state';
|
||||
import { ownFxEntries } from '../song/ownFx';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
@@ -241,13 +243,20 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
}
|
||||
}
|
||||
|
||||
/** Previews straight into the asset while editing; the command records the value from before the preview. */
|
||||
/**
|
||||
* Previews straight into the asset while editing; the command records the value from before the
|
||||
* preview. A song's Sound FX may also be one of its own (#355).
|
||||
*/
|
||||
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
|
||||
const spec = assetValue(host.state, assetId, def.key);
|
||||
const isSongFx = def.key === ASSET_FX_KEY && findAsset(host.state.project, assetId)?.type === 'song';
|
||||
const place = (id: string) => new SetAssetPropertyCommand(assetId, def.key, id);
|
||||
const own = isSongFx ? (current: string) => ownFxEntries(host.state, assetId, current, place) : undefined;
|
||||
return {
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||
...structFields<Asset>(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })),
|
||||
...(own ? { moreChoices: own } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
type MenuItem,
|
||||
NumberField,
|
||||
type Size,
|
||||
@@ -98,6 +99,11 @@ export interface FieldHandlers {
|
||||
* what it holds.
|
||||
*/
|
||||
structFieldHandlers?: (path: readonly string[], field: StructFieldDef) => FieldHandlers;
|
||||
/**
|
||||
* What the menu of a reference row ends with, asked on every open with the id it holds – a sound
|
||||
* FX the song makes for that place alone (#355). Only the caller knows the place.
|
||||
*/
|
||||
moreChoices?: (current: string) => MenuEntry[];
|
||||
}
|
||||
|
||||
export interface FieldRow {
|
||||
@@ -457,7 +463,7 @@ function referenceField(
|
||||
const labelOf = (id: string) => (id === '' ? none : ref.label(ctx.project(), id));
|
||||
const button = new Dropdown(labelOf(current));
|
||||
button.onClick = () => {
|
||||
const items: MenuItem[] = [
|
||||
const items: MenuEntry[] = [
|
||||
{ label: none, current: current === '', onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while the
|
||||
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
|
||||
@@ -466,6 +472,7 @@ function referenceField(
|
||||
current: choice.id === current,
|
||||
onSelect: () => handlers.onChange(choice.id),
|
||||
})),
|
||||
...(handlers.moreChoices?.(current) ?? []),
|
||||
];
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TRACK_MAX_DB,
|
||||
TRACK_MIN_DB,
|
||||
} from '../../core/music/song';
|
||||
import { ownFxEntries } from '../song/ownFx';
|
||||
import { createFieldRow } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -155,17 +156,20 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
|
||||
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
|
||||
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
|
||||
for (const def of TRACK_ROWS) {
|
||||
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
|
||||
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
||||
read: (target) => target[def.key as keyof SongTrack],
|
||||
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
||||
commit: (value) =>
|
||||
const commit = (value: SongTrack[keyof SongTrack]) =>
|
||||
new SetSongCommand(
|
||||
asset.id,
|
||||
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
|
||||
`Set track ${def.key}`,
|
||||
),
|
||||
);
|
||||
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
|
||||
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
||||
read: (target) => target[def.key as keyof SongTrack],
|
||||
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
||||
commit,
|
||||
});
|
||||
if (def.key === FX_ROW.key)
|
||||
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
||||
const row = createFieldRow(host, def, track[def.key as keyof SongTrack], handlers);
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
@@ -180,17 +184,20 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
||||
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)) {
|
||||
const handlers = previewingHandlers<SongItem, SongItem[keyof SongItem]>(host, {
|
||||
target: () => findAssetOf(state.project, asset.id, 'song')?.items.find((i) => i.id === item.id),
|
||||
read: (target) => target[def.key as keyof SongItem],
|
||||
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
||||
commit: (value) =>
|
||||
const commit = (value: SongItem[keyof SongItem]) =>
|
||||
new SetSongCommand(
|
||||
asset.id,
|
||||
{ items: withItem(state.project, asset.id, item.id, { [def.key]: value }) },
|
||||
`Set item ${def.key}`,
|
||||
),
|
||||
);
|
||||
const handlers = previewingHandlers<SongItem, SongItem[keyof SongItem]>(host, {
|
||||
target: () => findAssetOf(state.project, asset.id, 'song')?.items.find((i) => i.id === item.id),
|
||||
read: (target) => target[def.key as keyof SongItem],
|
||||
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
||||
commit,
|
||||
});
|
||||
if (def.key === FX_ROW.key)
|
||||
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
||||
const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers);
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
|
||||
@@ -10,10 +10,11 @@ import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands';
|
||||
import {
|
||||
embeddedCopy,
|
||||
embeddedPatternName,
|
||||
embeddedName,
|
||||
embeddedUsedBy,
|
||||
instrumentNear,
|
||||
isEmbedded,
|
||||
markedName,
|
||||
listedAssets,
|
||||
songClockProps,
|
||||
} from '../../core/music/embedded';
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
} from '../music/timeView';
|
||||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||||
import type { EditorState } from '../state';
|
||||
import { ownFxEntries } from './ownFx';
|
||||
import { removeTrack } from './songActions';
|
||||
import {
|
||||
clampMove,
|
||||
@@ -127,8 +129,6 @@ const SOLO_COLOR = 0xc9c23a;
|
||||
const FX_COLOR = 0x8fb8de;
|
||||
/** Height of an item's name strip; what it plays is drawn below. */
|
||||
const LABEL_STRIP = 15;
|
||||
/** In front of the name of what the song keeps to itself (#355). */
|
||||
export const EMBEDDED_MARK = '•';
|
||||
/** The letter on a lit mute, solo or FX button. */
|
||||
const DARK_LABEL = textStyle('small').clone();
|
||||
DARK_LABEL.fill = 0x30343c;
|
||||
@@ -1068,7 +1068,7 @@ export class SongTimeline extends Widget {
|
||||
const start = snapDown(tick, songGrid(asset.props));
|
||||
const create = new CreateAssetCommand(
|
||||
'pattern',
|
||||
embeddedPatternName(project, asset.id),
|
||||
embeddedName(project, asset.id, 'Pattern'),
|
||||
{
|
||||
...songClockProps(asset.props),
|
||||
[PATTERN_BARS_KEY]: 1,
|
||||
@@ -1098,14 +1098,13 @@ export class SongTimeline extends Widget {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
|
||||
/** The sound FX a track goes through (#346): none, one of the project's by name, or its own (#355). */
|
||||
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
|
||||
const set = (fx: string) =>
|
||||
this.commit(
|
||||
asset,
|
||||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
|
||||
'Set track sound FX',
|
||||
);
|
||||
const parts = (fx: string) => ({
|
||||
tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)),
|
||||
});
|
||||
const place = (fx: string) => new SetSongCommand(asset.id, parts(fx), 'Set track sound FX');
|
||||
const set = (fx: string) => this.commit(asset, parts(fx), 'Set track sound FX');
|
||||
const effects = listedAssets(this.state.project)
|
||||
.filter((a) => a.type === 'soundfx')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1113,6 +1112,7 @@ export class SongTimeline extends Widget {
|
||||
{ label: 'No sound FX', current: track.fx === '', onSelect: () => set('') },
|
||||
...(effects.length > 0 ? [{ separator: true as const }] : []),
|
||||
...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })),
|
||||
...ownFxEntries(this.state, asset.id, track.fx, place),
|
||||
]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
@@ -1247,8 +1247,7 @@ export class SongTimeline extends Widget {
|
||||
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
|
||||
private labelOf(item: SongItem): string {
|
||||
const asset = findAsset(this.state.project, item.asset);
|
||||
if (!asset) return 'missing';
|
||||
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
|
||||
return asset ? markedName(asset) : 'missing';
|
||||
}
|
||||
|
||||
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* A sound FX of its own for a song, a track or an item (#355): the entries every pick of a sound FX
|
||||
* in a song ends with. The FX made here is kept by the song and named by that one place, so the
|
||||
* place letting go of it takes it away (`settleEmbedded`).
|
||||
*/
|
||||
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import type { SoundFxAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { EMBEDDED_MARK, embeddedName, passThroughFxGraph } from '../../core/music/embedded';
|
||||
import type { MenuEntry } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** The command that makes the place name the sound FX of this id. */
|
||||
export type FxPlace = (fxId: string) => Command;
|
||||
|
||||
/**
|
||||
* Its own FX with Edit and Make project asset while the place names one; else Create sound FX, and
|
||||
* Embed a copy while it names one of the project's.
|
||||
*/
|
||||
export function ownFxEntries(
|
||||
state: EditorState,
|
||||
songId: string,
|
||||
current: string,
|
||||
place: FxPlace,
|
||||
): MenuEntry[] {
|
||||
const project = state.project;
|
||||
if (!findAssetOf(project, songId, 'song')) return [];
|
||||
const picked = findAssetOf(project, current, 'soundfx');
|
||||
if (picked?.owner === songId)
|
||||
return [
|
||||
{ separator: true },
|
||||
{ label: `${EMBEDDED_MARK} ${picked.name}`, current: true, onSelect: () => state.openAsset(picked.id) },
|
||||
{ label: 'Edit sound FX', onSelect: () => state.openAsset(picked.id) },
|
||||
{ label: 'Make project asset', onSelect: () => state.execute(new MakeProjectAssetCommand(picked.id)) },
|
||||
];
|
||||
return [
|
||||
{ separator: true },
|
||||
{ label: 'Create sound FX', onSelect: () => createOwnFx(state, songId, place) },
|
||||
...(picked ? [{ label: 'Embed a copy', onSelect: () => createOwnFx(state, songId, place, picked) }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sound FX the song keeps for the place, named there and opened right away, in one undo step: one
|
||||
* that lets the sound through, or a copy of `from`.
|
||||
*/
|
||||
export function createOwnFx(
|
||||
state: EditorState,
|
||||
songId: string,
|
||||
place: FxPlace,
|
||||
from?: SoundFxAsset,
|
||||
): string | null {
|
||||
const name = from?.name ?? embeddedName(state.project, songId, 'Sound FX');
|
||||
const create = new CreateAssetCommand('soundfx', name, {}, songId);
|
||||
const fx = create.asset as SoundFxAsset;
|
||||
fx.graph = from ? structuredClone(from.graph) : passThroughFxGraph();
|
||||
if (from) fx.props = structuredClone(from.props);
|
||||
const label = from ? 'Embed a copy' : 'Create sound FX';
|
||||
if (!state.execute(new CompoundCommand(label, [create, place(fx.id)]))) return null;
|
||||
state.openAsset(fx.id);
|
||||
return fx.id;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
assetTitle,
|
||||
embeddedCopy,
|
||||
embeddedIn,
|
||||
embeddedPatternName,
|
||||
embeddedName,
|
||||
embeddedUsedBy,
|
||||
instrumentNear,
|
||||
isRowHiddenWhenEmbedded,
|
||||
@@ -234,7 +234,7 @@ describe('a pattern made in a song (#355)', () => {
|
||||
const make = (props: Record<string, string | number> = {}) => {
|
||||
const made = new CreateAssetCommand(
|
||||
'pattern',
|
||||
embeddedPatternName(f.project, songId),
|
||||
embeddedName(f.project, songId, 'Pattern'),
|
||||
{ ...songClockProps(s.song().props), bars: 1, ...props },
|
||||
songId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* A sound FX of its own for a song, a track or an item (#355): made from the place's pick, kept by
|
||||
* the song, gone with the place letting go of it, all in one undo step each.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateSoundCommand,
|
||||
DuplicateAssetCommand,
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
||||
import type { SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject, findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import { embeddedIn, listedAssets } from '../../src/core/music/embedded';
|
||||
import { newItem, type SongItem, type SongTrack } from '../../src/core/music/song';
|
||||
import { ASSET_FX_KEY } from '../../src/core/properties/assetFx';
|
||||
import { assetRefLabel } from '../../src/core/properties/assetRef';
|
||||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||||
import { createOwnFx, type FxPlace, ownFxEntries } from '../../src/editor/song/ownFx';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import type { MenuEntry } from '../../src/ui';
|
||||
|
||||
const FILE = `${'c4'.repeat(32)}.wav`;
|
||||
|
||||
/** A song "Theme" with a project sound "Blip" on an item and a project sound FX "Echo". */
|
||||
function setup() {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const song = new CreateAssetCommand('song', 'Theme');
|
||||
const echo = new CreateAssetCommand('soundfx', 'Echo');
|
||||
const blip = new CreateSoundCommand('Blip', FILE);
|
||||
for (const c of [song, echo, blip]) expect(state.execute(c)).toBe(true);
|
||||
const songId = song.asset.id;
|
||||
const theme = () => findAssetOf(state.project, songId, 'song')!;
|
||||
const trackId = theme().tracks[0]!.id;
|
||||
const item = newItem({ track: trackId, kind: 'sound', asset: blip.asset.id, tick: 0, length: 1920 });
|
||||
state.execute(new SetSongCommand(songId, { items: [item] }));
|
||||
const track = () => theme().tracks.find((t) => t.id === trackId)!;
|
||||
const onTrack: FxPlace = (fx) =>
|
||||
new SetSongCommand(songId, {
|
||||
tracks: theme().tracks.map((t): SongTrack => (t.id === trackId ? { ...t, fx } : t)),
|
||||
});
|
||||
const onItem: FxPlace = (fx) =>
|
||||
new SetSongCommand(songId, { items: theme().items.map((i): SongItem => ({ ...i, fx })) });
|
||||
const onSong: FxPlace = (fx) => new SetAssetPropertyCommand(songId, ASSET_FX_KEY, fx);
|
||||
return { state, songId, theme, track, echoId: echo.asset.id, onTrack, onItem, onSong };
|
||||
}
|
||||
|
||||
const labels = (entries: MenuEntry[]) => entries.flatMap((e) => ('label' in e ? [e.label] : []));
|
||||
|
||||
describe('a sound FX of its own in a song (#355)', () => {
|
||||
it('is made for a track in one step, lets the sound through and is opened', () => {
|
||||
const { state, songId, track, onTrack } = setup();
|
||||
expect(labels(ownFxEntries(state, songId, '', onTrack))).toEqual(['Create sound FX']);
|
||||
const id = createOwnFx(state, songId, onTrack)!;
|
||||
const fx = findAssetOf(state.project, id, 'soundfx')!;
|
||||
expect([track().fx, fx.owner, fx.name]).toEqual([id, songId, 'Sound FX 1']);
|
||||
expect(compileSoundFx(fx.graph)).toMatchObject({ reason: '', effects: 0 });
|
||||
expect(listedAssets(state.project).map((a) => a.id)).not.toContain(id);
|
||||
expect(state.selection).toEqual({ kind: 'asset', assetId: id });
|
||||
expect(assetRefLabel(state.project, id)).toBe('• Sound FX 1');
|
||||
state.undo();
|
||||
expect([track().fx, findAsset(state.project, id)]).toEqual(['', undefined]);
|
||||
});
|
||||
|
||||
it('is offered to edit or to make a project asset while the place names it', () => {
|
||||
const { state, songId, onTrack } = setup();
|
||||
const id = createOwnFx(state, songId, onTrack)!;
|
||||
expect(labels(ownFxEntries(state, songId, id, onTrack))).toEqual([
|
||||
'• Sound FX 1',
|
||||
'Edit sound FX',
|
||||
'Make project asset',
|
||||
]);
|
||||
expect(createOwnFx(state, songId, onTrack)).not.toBeNull();
|
||||
expect(embeddedIn(state.project, songId).map((a) => a.name)).toEqual(['Sound FX 2']);
|
||||
});
|
||||
|
||||
it('goes when its track picks another one, and comes back on undo', () => {
|
||||
const { state, songId, track, echoId, onTrack } = setup();
|
||||
const id = createOwnFx(state, songId, onTrack)!;
|
||||
state.execute(onTrack(echoId));
|
||||
expect([track().fx, findAsset(state.project, id)]).toEqual([echoId, undefined]);
|
||||
state.undo();
|
||||
expect([track().fx, findAsset(state.project, id)?.owner]).toEqual([id, songId]);
|
||||
});
|
||||
|
||||
it('of the song itself stays through edits of the song and goes once it picks another', () => {
|
||||
const { state, songId, theme, onSong, onTrack } = setup();
|
||||
const id = createOwnFx(state, songId, onSong)!;
|
||||
expect(theme().props[ASSET_FX_KEY]).toBe(id);
|
||||
createOwnFx(state, songId, onTrack);
|
||||
expect(findAsset(state.project, id)?.owner).toBe(songId);
|
||||
state.execute(new SetAssetPropertyCommand(songId, ASSET_FX_KEY, ''));
|
||||
expect(findAsset(state.project, id)).toBeUndefined();
|
||||
state.undo();
|
||||
expect(findAsset(state.project, id)?.owner).toBe(songId);
|
||||
state.execute(new ResetAssetPropertyCommand(songId, ASSET_FX_KEY));
|
||||
expect(findAsset(state.project, id)).toBeUndefined();
|
||||
expect(embeddedIn(state.project, songId).map((a) => a.name)).toEqual(['Sound FX 2']);
|
||||
});
|
||||
|
||||
it('is a copy of a project FX for an item to bend, leaving the original alone', () => {
|
||||
const { state, songId, theme, echoId, onItem } = setup();
|
||||
state.execute(onItem(echoId));
|
||||
const echo = findAssetOf(state.project, echoId, 'soundfx')!;
|
||||
expect(labels(ownFxEntries(state, songId, echoId, onItem))).toEqual(['Create sound FX', 'Embed a copy']);
|
||||
const id = createOwnFx(state, songId, onItem, echo)!;
|
||||
const copy = findAsset(state.project, id) as SoundFxAsset;
|
||||
expect([theme().items[0]!.fx, copy.owner, copy.name]).toEqual([id, songId, 'Echo']);
|
||||
expect(copy.graph).toEqual(echo.graph);
|
||||
expect(copy.graph).not.toBe(echo.graph);
|
||||
expect(echo.owner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('of the song and of a track is copied along with a duplicate of the song', () => {
|
||||
const { state, songId, onSong, onTrack } = setup();
|
||||
const own = [createOwnFx(state, songId, onSong)!, createOwnFx(state, songId, onTrack)!];
|
||||
const dup = new DuplicateAssetCommand(songId);
|
||||
state.execute(dup);
|
||||
const copy = findAssetOf(state.project, dup.copyId, 'song')!;
|
||||
const named = [copy.props[ASSET_FX_KEY] as string, copy.tracks[0]!.fx];
|
||||
expect(named.some((id) => own.includes(id))).toBe(false);
|
||||
expect(named.map((id) => findAsset(state.project, id)?.owner)).toEqual([dup.copyId, dup.copyId]);
|
||||
});
|
||||
});
|
||||
@@ -1129,6 +1129,62 @@ export async function song(t) {
|
||||
JSON.stringify((await model()).items.map((item) => item.id)),
|
||||
);
|
||||
|
||||
// 65p. A sound FX of the track's own (#355), from the menu of its FX button: it lets the sound
|
||||
// through, is opened in its tab and named there with a mark. Picking another lets it go, and
|
||||
// a Ctrl+Z brings it back.
|
||||
const fxTrackBefore = (await model()).tracks[0].fx;
|
||||
p = await json("__gm.song.headAt(0, 'fx')");
|
||||
await click(p.x, p.y);
|
||||
await sleep(150);
|
||||
const createRows = await json('__gm.menuRows()');
|
||||
await clickText('Create sound FX');
|
||||
await sleep(250);
|
||||
const ownFxId = JSON.stringify((await model()).tracks[0].fx);
|
||||
const ownFx = await json(
|
||||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${ownFxId}); return a ? { name: a.name, owner: a.owner, type: a.type, nodes: a.graph.nodes.map((n) => n.type), edges: a.graph.edges.length } : null; })()`,
|
||||
);
|
||||
const ownFxTab = await json(
|
||||
`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${ownFxId}], row: __gm.assetRow(${ownFxId}) }`,
|
||||
);
|
||||
check(
|
||||
"Create sound FX on a track's FX button: one of the song's own, Input to Output, opened in its tab",
|
||||
createRows?.includes('Create sound FX') &&
|
||||
ownFxId !== JSON.stringify(fxTrackBefore) &&
|
||||
ownFx?.type === 'soundfx' &&
|
||||
ownFx.owner === id &&
|
||||
ownFx.name === 'Sound FX 1' &&
|
||||
JSON.stringify(ownFx.nodes) === JSON.stringify(['SoundFxInput', 'SoundFxOutput']) &&
|
||||
ownFx.edges === 1 &&
|
||||
ownFxTab.title === `${songName} › Sound FX 1` &&
|
||||
ownFxTab.row === null,
|
||||
JSON.stringify({ createRows, ownFx, ownFxTab }),
|
||||
);
|
||||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||
await sleep(200);
|
||||
p = await json("__gm.song.headAt(0, 'fx')");
|
||||
await click(p.x, p.y);
|
||||
await sleep(150);
|
||||
const ownRows = await json('__gm.menuRows()');
|
||||
await clickText('No sound FX');
|
||||
await sleep(150);
|
||||
const letGo = await json(`__gm.state.project.assets.some((a) => a.id === ${ownFxId})`);
|
||||
await press('z', { ctrl: true });
|
||||
await sleep(150);
|
||||
const backAgain = await json(
|
||||
`{ fx: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).tracks[0].fx, owner: __gm.state.project.assets.find((a) => a.id === ${ownFxId})?.owner }`,
|
||||
);
|
||||
check(
|
||||
'its menu names it with Edit and Make project asset, No sound FX lets it go, Ctrl+Z brings it back',
|
||||
ownRows?.includes('• Sound FX 1') &&
|
||||
ownRows.includes('Edit sound FX') &&
|
||||
ownRows.includes('Make project asset') &&
|
||||
!ownRows.includes('Create sound FX') &&
|
||||
letGo === false &&
|
||||
JSON.stringify(backAgain.fx) === ownFxId &&
|
||||
backAgain.owner === id,
|
||||
JSON.stringify({ ownRows, letGo, backAgain }),
|
||||
);
|
||||
|
||||
// 65j. The step takes back everything it made.
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await mutate(`
|
||||
|
||||
Reference in new issue
Block a user