From 49669017b1666ba50deabb4826d8332125155a4c Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 8 Oct 2026 10:54:40 +0200 Subject: [PATCH] Make a pattern right in a song with a right-click (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pattern for one song had to be made in the Assets panel first and dragged in, and then stood in every picker of the project. Now a right-click on an empty spot of a track makes one the song keeps to itself, on top of the embedded assets of the first part. - Create pattern lays an item from the grid line before the spot, a bar long, and opens the pattern in its tab. Pattern, item and name are one undo step. The name is "Pattern 1", 2, … per song. - It plays the instrument of the track's pattern item that starts last before the spot, else of the first one after it. An item's own pick counts before its pattern's. - An embedded pattern runs on the song's clock. It takes the tempo and the meter of the song, and a change on the song moves it along in the same undo step (`AssetPropertyCommand`). - Its rows Tempo, Beats per bar, Beat and Bus are hidden, as the song decides them. Length, grid, instrument and sound FX stay. - A copy that comes to a song (Embed a copy, a paste from another song) takes that song's clock. Made a project asset, it keeps the values it had. - `CreateAssetCommand` takes an owner. `embeddedCopy` takes the song now, not its id. - The cleanup of e2e 65 also takes what the song keeps. Tests: - Unit embedded: 18 green (5 new), 6 counter-checks each red - Unit take, song, pattern and the asset commands: green - e2e 65 (melektaus-atx): ALL PASSED, 49 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4172 tests Co-Authored-By: Claude Opus 5.5 --- src/core/commands/assetCommands.ts | 30 ++++++- src/core/music/embedded.ts | 78 ++++++++++++++++-- src/editor/song/SongTimeline.ts | 61 ++++++++++++-- tests/core/embedded.test.ts | 123 ++++++++++++++++++++++++++++- tools/e2e/65-song.mjs | 63 ++++++++++++++- 5 files changed, 335 insertions(+), 20 deletions(-) diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index b3058fa9..abeafa82 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -12,7 +12,7 @@ import type { VariablesAsset, } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; -import { embeddedIn } from '../music/embedded'; +import { clockFollowers, embeddedIn } from '../music/embedded'; import { defaultTracks } from '../music/song'; import { heldSongGraph } from '../music/routing'; import { linearCurve } from '../model/curve'; @@ -21,7 +21,7 @@ import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs'; -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties'; import { objectHolders, type Project } from '../model/Project'; import { type Scope, @@ -130,13 +130,18 @@ export class CreateAssetCommand implements Command { readonly asset: Asset; private previousActiveScene = ''; - /** `props` replace schema defaults and pass through the schema like any other edit. */ + /** + * `props` replace schema defaults and pass through the schema like any other edit. `owner` makes + * it an asset the song of that id keeps to itself (#355). + */ constructor( type: AssetType, name: string, private readonly props: PropertyBag = {}, + owner = '', ) { this.asset = buildAsset(type, name); + if (owner) this.asset.owner = owner; this.label = `Create ${type} "${name}"`; } @@ -534,7 +539,10 @@ export class SetMachineStartCommand implements Command { } } +/** An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. */ abstract class AssetPropertyCommand extends SetBagValueCommand { + private followed: { id: string; previous: PropertyValue | undefined }[] = []; + protected constructor( private readonly assetId: string, key: string, @@ -543,6 +551,22 @@ abstract class AssetPropertyCommand extends SetBagValueCommand { super(key, label); } + override execute(project: Project): void { + super.execute(project); + const asset = requireAsset(project, this.assetId); + this.followed = clockFollowers(project, asset, this.key).map((pattern) => { + const previous = pattern.props[this.key]; + setBagValue(pattern.props, this.key, asset.props[this.key]); + return { id: pattern.id, previous }; + }); + } + + override undo(project: Project): void { + for (const { id, previous } of this.followed) + setBagValue(requireAsset(project, id).props, this.key, previous); + super.undo(project); + } + protected override resolveTarget(project: Project): Asset { return requireAsset(project, this.assetId); } diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index 31e6a008..1f0f5e4e 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -6,17 +6,30 @@ * or a paste gets a copy of its own and what nothing uses any more goes. */ import { newId } from '../ids'; -import type { Asset, SongAsset } from '../model/Asset'; +import type { Asset, PatternAsset, SongAsset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; -import { findAsset, type Project } from '../model/Project'; +import type { PropertyBag } from '../model/Properties'; +import { findAsset, findAssetOf, type Project } from '../model/Project'; import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx'; -import type { SongItem, SongTrack } from './song'; +import { nextNumberedName } from '../commands/naming'; +import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern'; +import { type SongItem, songMeter, songTempo, type SongTrack } from './song'; export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; -/** The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the song's. */ +/** + * What an embedded pattern takes from its song: it is drawn and heard on the song's clock. A pattern + * names these the way a song does, so one key serves both. + */ +export const SONG_CLOCK_KEYS: readonly string[] = [PATTERN_TEMPO_KEY, PATTERN_BEATS_KEY, PATTERN_BEAT_KEY]; + +/** + * The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the + * song's, and a pattern's clock is the song's too. + */ const HIDDEN_ROWS: Partial> = { sound: ['loop', 'bus'], + pattern: [...SONG_CLOCK_KEYS, 'bus'], }; export function isEmbeddable(type: AssetType): boolean { @@ -52,9 +65,60 @@ export function assetTitle(project: Project, asset: Asset): string { return song ? `${song.name} › ${asset.name}` : asset.name; } +/** The tempo and the meter of a song, as a pattern's props. */ +export function songClockProps(props: PropertyBag): PropertyBag { + const meter = songMeter(props); + return { + [PATTERN_TEMPO_KEY]: songTempo(props), + [PATTERN_BEATS_KEY]: meter.beats, + [PATTERN_BEAT_KEY]: String(meter.unit), + }; +} + +/** Gives an asset that comes to live in `song` its owner, and a pattern the song's clock. */ +function ownedBy(asset: T, song: SongAsset): T { + asset.owner = song.id; + if (asset.type === 'pattern') Object.assign(asset.props, songClockProps(song.props)); + 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, songId: string): Asset { - return { ...structuredClone(asset), id: newId(), owner: songId }; +export function embeddedCopy(asset: Asset, song: SongAsset): Asset { + return ownedBy({ ...structuredClone(asset), id: newId() }, song); +} + +/** The patterns that follow `key` of an asset: those a song keeps, when the key is part of its clock. */ +export function clockFollowers(project: Project, asset: Asset, key: string): PatternAsset[] { + if (asset.type !== 'song' || !SONG_CLOCK_KEYS.includes(key)) return []; + 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 { + return nextNumberedName( + embeddedIn(project, songId).map((a) => a.name), + 'Pattern', + ); +} + +/** + * 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. + */ +export function instrumentNear( + project: Project, + items: readonly SongItem[], + track: string, + tick: number, +): string { + const patterns = items + .filter((i) => i.track === track && i.kind === 'pattern') + .sort((a, b) => a.tick - b.tick); + const near = patterns.filter((i) => i.tick <= tick).at(-1) ?? patterns[0]; + if (!near) return ''; + if (near.instrument !== '') return near.instrument; + const pattern = findAssetOf(project, near.asset, 'pattern'); + return pattern ? patternInstrument(pattern.props) : ''; } /** A place in a song that names an asset, in the order the places claim what they name. */ @@ -117,7 +181,7 @@ export function settleEmbedded( } if (index === 0) return; const id = found || taken.has(source.id) ? newId() : source.id; - const copy = { ...structuredClone(source), id, owner: song.id }; + const copy = ownedBy({ ...structuredClone(source), id }, song); taken.add(id); added.push(copy); claimed.add(id); diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 79bbe23e..dabab6b9 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -5,10 +5,19 @@ import { Graphics, type Text, } from 'pixi.js'; -import { MakeProjectAssetCommand } from '../../core/commands/assetCommands'; +import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; +import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands'; -import { embeddedCopy, embeddedUsedBy, isEmbedded, listedAssets } from '../../core/music/embedded'; -import { patternLength } from '../../core/music/pattern'; +import { + embeddedCopy, + embeddedPatternName, + embeddedUsedBy, + instrumentNear, + isEmbedded, + 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 { itemEnd, @@ -1015,11 +1024,11 @@ export class SongTimeline extends Widget { ]).open(global.x, global.y); } - /** The menu of the item under a right-click, picked first unless it is already. */ + /** The menu of the item under a right-click, picked first unless it is already; off one, of the spot. */ private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void { const at = itemAt(this.view, asset.items, asset.tracks, g); const hit = at && asset.items.find((i) => i.id === at.id); - if (!hit) return; + 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 embedded = played !== undefined && isEmbedded(played); @@ -1036,11 +1045,51 @@ export class SongTimeline extends Widget { ]).open(global.x, global.y); } + /** The menu of an empty spot on a track. */ + private openSpotMenu(asset: SongAsset, g: Vec2, global: Vec2): void { + const row = trackAtY(this.view, asset.tracks.length, g.y); + const tick = xToTick(this.view, g.x); + if (row === null || tick < 0) return; + new Menu([{ label: 'Create pattern', onSelect: () => this.createPattern(asset, row, tick) }]).open( + global.x, + global.y, + ); + } + + /** + * A pattern the song keeps to itself (#355), a bar long on the song's clock, on an item from the + * grid line before `tick` on; opened right away. It plays the instrument of the track's pattern + * nearest before it. + */ + createPattern(asset: SongAsset, row: number, tick: number): boolean { + const track = asset.tracks[row]; + if (!track || tick < 0) return false; + const project = this.state.project; + const start = snapDown(tick, songGrid(asset.props)); + const create = new CreateAssetCommand( + 'pattern', + embeddedPatternName(project, asset.id), + { + ...songClockProps(asset.props), + [PATTERN_BARS_KEY]: 1, + [PATTERN_INSTRUMENT_KEY]: instrumentNear(project, asset.items, track.id, start), + }, + asset.id, + ); + const length = ticksPerBar(songMeter(asset.props)); + const item = newItem({ track: track.id, kind: 'pattern', asset: create.asset.id, tick: start, length }); + const placed = new SetSongCommand(asset.id, { items: [...asset.items, item] }, 'Create pattern'); + if (!this.state.execute(new CompoundCommand('Create pattern', [create, placed]))) return false; + this.pick([item.id]); + this.onOpenItem(item, item.tick); + return true; + } + /** 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); if (!played || isEmbedded(played)) return false; - const copy = embeddedCopy(played, asset.id); + const copy = embeddedCopy(played, asset); this.commit( asset, { items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] }, diff --git a/tests/core/embedded.test.ts b/tests/core/embedded.test.ts index c4d64d77..9828b7e8 100644 --- a/tests/core/embedded.test.ts +++ b/tests/core/embedded.test.ts @@ -10,19 +10,25 @@ import { DeleteAssetCommand, DuplicateAssetCommand, MakeProjectAssetCommand, + ResetAssetPropertyCommand, + SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { nextAssetName } from '../../src/core/commands/naming'; import { SetSongCommand } from '../../src/core/commands/songCommands'; -import type { Asset, SongAsset } from '../../src/core/model/Asset'; +import type { Asset, PatternAsset, SongAsset } from '../../src/core/model/Asset'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { assetTitle, embeddedCopy, embeddedIn, + embeddedPatternName, embeddedUsedBy, + instrumentNear, isRowHiddenWhenEmbedded, listedAssets, + songClockProps, } from '../../src/core/music/embedded'; +import { patternLength, patternMeter, patternTempo } from '../../src/core/music/pattern'; import { newItem, type SongItem, songSequence } from '../../src/core/music/song'; import { assetRefChoices } from '../../src/core/properties/assetRef'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; @@ -151,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().id); + 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); @@ -211,3 +217,116 @@ describe('an embedded asset (#355)', () => { expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined(); }); }); + +describe('a pattern made in a song (#355)', () => { + /** The song of `setup` at 140 BPM in 3/4, with a project pattern "Loose" and two instruments. */ + function withPatterns() { + const s = setup(); + const { f, songId } = s; + f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 140)); + f.stack.execute(new SetAssetPropertyCommand(songId, 'beats', 3)); + const loose = new CreateAssetCommand('pattern', 'Loose'); + f.stack.execute(loose); + const lead = new CreateAssetCommand('instrument', 'Lead'); + const bass = new CreateAssetCommand('instrument', 'Bass'); + f.stack.execute(lead); + f.stack.execute(bass); + const make = (props: Record = {}) => { + const made = new CreateAssetCommand( + 'pattern', + embeddedPatternName(f.project, songId), + { ...songClockProps(s.song().props), bars: 1, ...props }, + songId, + ); + f.stack.execute(made); + return made.asset as PatternAsset; + }; + return { ...s, loose: loose.asset as PatternAsset, leadId: lead.asset.id, bassId: bass.asset.id, make }; + } + + it('is a bar of the song on its clock, numbered among what the song keeps', () => { + const { f, songId, make } = withPatterns(); + f.stack.execute(new CreateAssetCommand('pattern', 'Pattern 1')); + const first = make(); + expect([first.name, first.owner, patternTempo(first.props)]).toEqual(['Pattern 1', songId, 140]); + expect(patternMeter(first.props)).toEqual({ beats: 3, unit: 4 }); + expect(patternLength(first)).toBe(3 * 480); + expect(make().name).toBe('Pattern 2'); + }); + + it('follows the tempo and the meter of its song in the same undo step, and nothing else', () => { + const { f, songId, loose, make } = withPatterns(); + const own = make(); + f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 90)); + expect([patternTempo(own.props), patternTempo(loose.props)]).toEqual([90, 120]); + f.stack.execute(new SetAssetPropertyCommand(songId, 'beat', '8')); + expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 }); + f.stack.execute(new ResetAssetPropertyCommand(songId, 'beats')); + expect(patternMeter(own.props).beats).toBe(4); + const grid = own.props.grid; + f.stack.execute(new SetAssetPropertyCommand(songId, 'grid', '1/8')); + expect([own.props.grid, findAsset(f.project, songId)?.props.grid]).toEqual([grid, '1/8']); + f.stack.undo(); + f.stack.undo(); + expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 }); + f.stack.undo(); + f.stack.undo(); + expect([patternTempo(own.props), patternMeter(own.props).unit]).toEqual([140, 4]); + }); + + it('hides its clock and its bus, not its length, instrument or sound FX', () => { + const { loose, make } = withPatterns(); + const own = make(); + const keys = ['tempo', 'beats', 'beat', 'bus', 'bars', 'instrument', 'fx']; + expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([ + true, + true, + true, + true, + false, + false, + false, + ]); + expect(isRowHiddenWhenEmbedded(loose, 'tempo')).toBe(false); + }); + + it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => { + const { f, track, edit, song, takeId, leadId, bassId, make } = withPatterns(); + const withLead = make({ instrument: leadId }); + const other = make(); + const items = [ + ...song().items, + newItem({ track, kind: 'pattern', asset: withLead.id, tick: BAR, length: BAR }), + { + ...newItem({ track, kind: 'pattern', asset: other.id, tick: 3 * BAR, length: BAR }), + instrument: bassId, + }, + newItem({ track, kind: 'sound', asset: takeId, tick: 4 * BAR, length: BAR }), + ]; + edit(items); + const near = (tick: number, on = track) => instrumentNear(f.project, song().items, on, tick); + expect([near(0), near(2 * BAR), near(3 * BAR), near(5 * BAR)]).toEqual([leadId, leadId, bassId, bassId]); + expect(near(0, 'elsewhere')).toBe(''); + }); + + 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; + expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]); + const other = new CreateAssetCommand('song', 'Other'); + f.stack.execute(other); + const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1 }, other.asset.id); + f.stack.execute(theirs); + edit([ + ...song().items, + newItem({ track, kind: 'pattern', asset: theirs.asset.id, tick: 0, length: BAR }), + ]); + const named = findAssetOf(f.project, song().items.at(-1)!.asset, 'pattern')!; + expect(named.id).not.toBe(theirs.asset.id); + expect([named.owner, patternTempo(named.props), patternMeter(named.props).beats]).toEqual([ + song().id, + 140, + 3, + ]); + }); +}); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 07e0b503..68083f69 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -13,7 +13,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; * the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler: * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record - * takes chromium's test tone onto a track of its own (#342), Esc throws a take away. + * takes chromium's test tone onto a track of its own (#342), Esc throws a take away. A right-click + * on an empty spot of a track makes a pattern the song keeps to itself (#355). * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips * and what the buses were set to, as in e2e 64. * @@ -1070,12 +1071,70 @@ export async function song(t) { JSON.stringify({ fxGone: fxGone.map((b) => b.name), openedSound }), ); + // 65o. A right-click on an empty spot of a track makes a pattern of the song's own (#355): a bar + // from the grid line before it, with the instrument of the track's pattern, opened in its tab. + // One Ctrl+Z takes pattern and item back. + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); + const spotSong = await model(); + const riffItem = spotSong.items.find((item) => item.id === patternItem.id); + const spotRow = spotSong.tracks.findIndex((track) => track.id === riffItem.track); + const onTrack = spotSong.items.filter((item) => item.track === riffItem.track); + let spotBar = Math.ceil((riffItem.tick + riffItem.length) / BAR); + while (onTrack.some((item) => item.tick < (spotBar + 1) * BAR && item.tick + item.length > spotBar * BAR)) + spotBar++; + p = await json(`__gm.song.laneAt(${spotBar * BAR + BAR / 4}, ${spotRow})`); + await click(p.x, p.y, 'right'); + await sleep(150); + const spotRows = await json('__gm.menuRows()'); + await clickText('Create pattern'); + await sleep(250); + const made = await model(); + const madeItem = made.items.find((item) => !spotSong.items.some((other) => other.id === item.id)); + const madeId = JSON.stringify(madeItem?.asset ?? ''); + const madePattern = await json( + `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${madeId}); return a ? { name: a.name, owner: a.owner, type: a.type, props: a.props, notes: a.notes.length } : null; })()`, + ); + const madeTab = await json( + `{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${madeId}], row: __gm.assetRow(${madeId}) }`, + ); + check( + "Create pattern on an empty spot: a bar of the song's own from the bar before, Lead 65, opened in its tab", + spotRows?.includes('Create pattern') && + madeItem?.kind === 'pattern' && + madeItem.track === riffItem.track && + madeItem.tick === spotBar * BAR && + madeItem.length === BAR && + madePattern?.type === 'pattern' && + madePattern.owner === id && + madePattern.name === 'Pattern 1' && + madePattern.notes === 0 && + madePattern.props.bars === 1 && + madePattern.props.instrument === 'sg65-ins' && + madePattern.props.tempo === 120 && + madePattern.props.beats === 4 && + madeTab.tab === 'pattern' && + madeTab.title === `${songName} › Pattern 1` && + madeTab.row === null, + JSON.stringify({ spotRows, spotBar, madeItem, madePattern, madeTab }), + ); + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); + await press('z', { ctrl: true }); + await sleep(150); + check( + 'one Ctrl+Z takes the pattern and its item back', + (await model()).items.length === spotSong.items.length && + (await json(`__gm.state.project.assets.some((a) => a.id === ${madeId})`)) === false, + JSON.stringify((await model()).items.map((item) => item.id)), + ); + // 65j. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(` const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}, 'sg65-fx']); const project = __gm.state.project; - project.assets = project.assets.filter((a) => !gone.has(a.id)); + project.assets = project.assets.filter((a) => !gone.has(a.id) && a.owner !== ${JSON.stringify(id)}); __gm.state.select(null);`); await showTab('scene'); check(