/** * 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('is offered to every place of its song, which share it (#357)', () => { const { state, songId, theme, track, onTrack, onItem } = setup(); const id = createOwnFx(state, songId, onTrack)!; const entries = ownFxEntries(state, songId, '', onItem); expect(labels(entries)).toEqual(['• Sound FX 1', 'Create sound FX']); const own = entries.find((e) => 'label' in e && e.label === '• Sound FX 1'); if (own && 'onSelect' in own) own.onSelect(); expect([track().fx, theme().items[0]!.fx]).toEqual([id, id]); state.execute(onTrack('')); expect(findAsset(state.project, id)?.owner).toBe(songId); }); 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]); }); });