diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index abeafa82..9b86252f 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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 { 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 { 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); diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index 1f0f5e4e..464efd4a 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -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. diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts index c2602db4..4ee1e49a 100644 --- a/src/core/properties/assetRef.ts +++ b/src/core/properties/assetRef.ts @@ -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, `?` 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, `?` + * 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}?`; } /** diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index cd4a60f4..ec026b85 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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(host, spec), ...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })), ...structFields(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })), + ...(own ? { moreChoices: own } : {}), }; } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 971efc64..2323b9de 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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); }; diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 85713d34..63617462 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -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 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(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) => - new SetSongCommand( - asset.id, - { tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) }, - `Set track ${def.key}`, - ), + 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 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(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) => - new SetSongCommand( - asset.id, - { items: withItem(state.project, asset.id, item.id, { [def.key]: value }) }, - `Set item ${def.key}`, - ), + 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); diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index dabab6b9..aabd5157 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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' { diff --git a/src/editor/song/ownFx.ts b/src/editor/song/ownFx.ts new file mode 100644 index 00000000..a327bcf4 --- /dev/null +++ b/src/editor/song/ownFx.ts @@ -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; +} diff --git a/tests/core/embedded.test.ts b/tests/core/embedded.test.ts index 9828b7e8..6d774768 100644 --- a/tests/core/embedded.test.ts +++ b/tests/core/embedded.test.ts @@ -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 = {}) => { const made = new CreateAssetCommand( 'pattern', - embeddedPatternName(f.project, songId), + embeddedName(f.project, songId, 'Pattern'), { ...songClockProps(s.song().props), bars: 1, ...props }, songId, ); diff --git a/tests/editor/own-fx.test.ts b/tests/editor/own-fx.test.ts new file mode 100644 index 00000000..798ac1af --- /dev/null +++ b/tests/editor/own-fx.test.ts @@ -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]); + }); +}); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 68083f69..b65fe1a3 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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(`