Keep a recorded take inside its song (#355)

A take used to land in the Assets panel as "Song – Take N", next to
every other sound of the project. Now a song can keep a sound, a
pattern or a sound FX to itself: an ordinary asset whose new field
`owner` names the song. Everything that finds an asset by its id
finds it, so playing, the tabs and the game need no change. Only
the places that list assets leave it out.

- `core/music/embedded.ts` holds the rules: which types can be
  embedded, `listedAssets`, the title "Song › Take 1", and the
  rows an embedded sound hides (Loop, Bus).
- One user per embedded asset. `SetSongCommand` settles the song
  after every edit: a place naming an asset an earlier place
  claimed, or one of another song, gets a copy, and what nothing
  names any more goes in the same undo step. The outcome is worked
  out once, so a redo brings back the same copies.
- A split, Ctrl+D and a paste get that for free. The clipboard
  keeps the embedded assets of the copied items, so a paste after a
  delete brings the take back.
- Deleting a song deletes what it keeps. Duplicating it copies
  that and points the copy at the copies. A duplicate of an
  embedded asset is a project asset.
- The item menu has Open, Make project asset and Embed a copy.
- An embedded item carries a bullet in front of its name. The
  first pick was a diamond, which the gate's font test refused:
  the UI font has no glyph for it.
- A take is "Take 1", 2, … numbered per song. With the song gone
  while it uploads, it becomes a project sound under a free name.
- Left out of: the Assets panel, every asset picker, the sound FX
  menu of a track, the test sound of the Sound FX tab and the
  naming of new assets. Delete on an embedded asset only says
  where it lives. A ping lights up its song.
- Loading drops an `owner` whose song is gone, the asset stays as
  a project asset. No migration: older projects have no owner.

Tests:
- Unit embedded (new): 13 green, 6 counter-checks each red
- Unit take, song, song-edit, asset-fx, asset-ref, serialize and
  neighbours: 116 green
- e2e 65 (melektaus-atx): ALL PASSED, 47 checks (1 new)
- Counter-check e2e 65 (asset panel unfiltered): exactly that
  check red
- Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4167 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 10:36:25 +02:00
1 parent 7a17030472
commit 23cb34f781
18 files changed
+565 -52

No files matched your search

+66 -7
View File
@@ -4,6 +4,7 @@ import { starterInstrumentGraph } from '../instrument/nodes';
import type {
Asset,
FontAsset,
SongAsset,
SoundAsset,
StateMachineAsset,
TextureAsset,
@@ -11,6 +12,7 @@ import type {
VariablesAsset,
} from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { embeddedIn } from '../music/embedded';
import { defaultTracks } from '../music/song';
import { heldSongGraph } from '../music/routing';
import { linearCurve } from '../model/curve';
@@ -32,8 +34,9 @@ import {
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { cellsWithout } from '../model/tilemap';
import { ASSET_FX_KEY } from '../properties/assetFx';
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { Recorder } from '../util/recorder';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
@@ -160,22 +163,45 @@ export class DuplicateAssetCommand implements Command {
readonly label = 'Duplicate asset';
readonly copyId = newId();
private copy?: Asset;
/** What a song keeps to itself, copied for the copy (#355). */
private owned: Asset[] = [];
constructor(private readonly assetId: string) {}
execute(project: Project): void {
const original = requireAsset(project, this.assetId);
this.copy ??= copyOf(original, this.copyId, nextAssetName(project, original.name));
if (!this.copy) {
this.copy = copyOf(original, this.copyId, nextAssetName(project, original.name));
if (this.copy.type === 'song') this.owned = copyEmbedded(project, original.id, this.copy);
}
project.assets.splice(project.assets.indexOf(original) + 1, 0, this.copy);
project.assets.push(...this.owned);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.copyId);
const gone = new Set([this.copyId, ...this.owned.map((a) => a.id)]);
project.assets = project.assets.filter((a) => !gone.has(a.id));
}
}
/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */
function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] {
const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()]));
const renamed = (id: string) => ids.get(id) ?? id;
for (const track of song.tracks) track.fx = renamed(track.fx);
for (const item of song.items) {
item.asset = renamed(item.asset);
item.fx = renamed(item.fx);
}
const fx = song.props[ASSET_FX_KEY];
if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx);
return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id }));
}
/** A copy is a project asset of its own, even one of something a song kept to itself. */
function copyOf(original: Asset, id: string, name: string): Asset {
const copy = { ...structuredClone(original), id, name };
delete copy.owner;
if (copy.type === 'texture' || copy.type === 'pixelart')
for (const frame of copy.frames) frame.id = newId();
if (copy.type === 'scene' || copy.type === 'container') {
@@ -259,13 +285,42 @@ export class CreateFontCommand extends ImportFileCommand<FontAsset> {
}
}
function buildSoundAsset(name: string, file: string): SoundAsset {
return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file };
function buildSoundAsset(name: string, file: string, owner: string): SoundAsset {
const sound: SoundAsset = { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file };
if (owner) sound.owner = owner;
return sound;
}
/** `owner` makes it a sound the song of that id keeps to itself (#355). */
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
constructor(name: string, file: string) {
super(buildSoundAsset(name, file), 'sound', isSoundFile);
constructor(name: string, file: string, owner = '') {
super(buildSoundAsset(name, file, owner), 'sound', isSoundFile);
}
}
/**
* Makes an asset a song kept to itself a project asset (#355): it shows in the lists, and a name a
* listed asset has already gets a number.
*/
export class MakeProjectAssetCommand implements Command {
readonly label = 'Make project asset';
private previous: { owner: string; name: string } | null = null;
constructor(private readonly assetId: string) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
if (!asset.owner) throw new CommandError(`"${asset.name}" is a project asset already`);
this.previous = { owner: asset.owner, name: asset.name };
delete asset.owner;
asset.name = nextAssetName(project, asset.name, asset.id);
}
undo(project: Project): void {
if (!this.previous) return;
const asset = requireAsset(project, this.assetId);
asset.owner = this.previous.owner;
asset.name = this.previous.name;
}
}
@@ -278,6 +333,8 @@ export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
export class DeleteAssetCommand implements Command {
readonly label = 'Delete asset';
private removed?: Removed<Asset>;
/** What a song kept to itself (#355) goes with it. */
private owned: Removed<Asset>[] = [];
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
private readonly references = new Recorder();
@@ -308,6 +365,7 @@ export class DeleteAssetCommand implements Command {
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
}
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
this.owned = removeWhere(project.assets, (a) => a.owner === this.assetId);
dropReferencesToGone(project, before, this.references);
this.previousActiveScene = project.activeSceneId;
if (project.activeSceneId === this.assetId) {
@@ -319,6 +377,7 @@ export class DeleteAssetCommand implements Command {
undo(project: Project): void {
if (!this.removed) return;
this.references.revert();
restoreAt(project.assets, this.owned);
restoreAt(project.assets, [this.removed]);
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
for (const entry of this.removedObjects) {
+6 -3
View File
@@ -8,10 +8,13 @@ function firstFree(taken: ReadonlySet<string>, candidate: (i: number) => string,
}
}
/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */
export function nextAssetName(project: Project, base: string): string {
/**
* "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by a listed asset other than
* `self`. What a song keeps to itself (#355) is named within the song.
*/
export function nextAssetName(project: Project, base: string, self = ''): string {
return nextFreeName(
project.assets.map((a) => a.name),
project.assets.filter((a) => !a.owner && a.id !== self).map((a) => a.name),
base,
);
}
+27 -3
View File
@@ -5,21 +5,33 @@
* routing follows the tracks (#350).
*/
import type { EdgeSend, Graph } from '../graph/Graph';
import type { SongAsset } from '../model/Asset';
import type { Asset, SongAsset } from '../model/Asset';
import type { Project } from '../model/Project';
import { settleEmbedded } from '../music/embedded';
import { heldSend, heldSongGraph } from '../music/routing';
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
import { heldLoop, type LoopRange } from '../music/sequence';
import { type Removed, removeWhere, restoreAt } from '../util/list';
import type { Command } from './Command';
import { requireEdge, requireSong } from './require';
export interface SongParts {
tracks?: readonly SongTrack[];
items?: readonly SongItem[];
/** Embedded assets copied along with items (#355), for places naming one that is gone by now. */
spare?: readonly Asset[];
}
/**
* Writes the tracks and items whole and settles what the song keeps to itself (#355) in the same
* step: copies for places that share an embedded asset, and away with the ones nothing names. The
* outcome is worked out once, so a redo brings back the very same copies.
*/
export class SetSongCommand implements Command {
private previous: { tracks: SongTrack[]; items: SongItem[]; graph: Graph } | null = null;
private outcome: { tracks: SongTrack[]; items: SongItem[]; added: Asset[]; dropped: Set<string> } | null =
null;
private removed: Removed<Asset>[] = [];
constructor(
private readonly assetId: string,
@@ -37,13 +49,25 @@ export class SetSongCommand implements Command {
execute(project: Project): void {
const asset = requireSong(project, this.assetId);
this.previous = { tracks: asset.tracks, items: asset.items, graph: asset.graph };
asset.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks);
asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks);
if (!this.outcome) {
const tracks = heldSongTracks(this.parts.tracks ?? asset.tracks);
const items = heldSongItems(this.parts.items ?? asset.items, tracks);
const { added, dropped } = settleEmbedded(project, asset, tracks, items, this.parts.spare);
this.outcome = { tracks, items, added, dropped: new Set(dropped) };
}
const { tracks, items, added, dropped } = this.outcome;
asset.tracks = tracks;
asset.items = items;
asset.graph = heldSongGraph(asset.graph, asset.tracks);
project.assets.push(...added);
this.removed = removeWhere(project.assets, (a) => dropped.has(a.id));
}
undo(project: Project): void {
if (!this.previous) return;
restoreAt(project.assets, this.removed);
const added = new Set(this.outcome?.added.map((a) => a.id));
project.assets = project.assets.filter((a) => !added.has(a.id));
const asset = requireSong(project, this.assetId);
asset.tracks = this.previous.tracks;
asset.items = this.previous.items;
+2
View File
@@ -13,6 +13,8 @@ export interface AssetBase {
type: AssetType;
name: string;
props: PropertyBag;
/** The song a sound, pattern or sound FX made in it belongs to (#355); absent on a project asset. */
owner?: string;
}
export interface SpriteAsset extends AssetBase {
+128
View File
@@ -0,0 +1,128 @@
/**
* What a song keeps to itself (#355): a sound, a pattern or a sound FX made in a song is an asset like
* any other, so everything that finds an asset by its id finds it, but its `owner` names the song.
* The lists of assets leave it out, and it has exactly one user in its song: the song itself, a
* 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 { newId } from '../ids';
import type { Asset, SongAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
import type { SongItem, 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. */
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
sound: ['loop', 'bus'],
};
export function isEmbeddable(type: AssetType): boolean {
return EMBEDDABLE_TYPES.includes(type);
}
export function isEmbedded(asset: Asset): boolean {
return (asset.owner ?? '') !== '';
}
/** 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));
}
export function embeddedIn(project: Project, songId: string): Asset[] {
return project.assets.filter((a) => a.owner === songId);
}
/** The song an asset belongs to; undefined for a project asset. */
export function ownerSong(project: Project, asset: Asset): SongAsset | undefined {
const owner = asset.owner ? findAsset(project, asset.owner) : undefined;
return owner?.type === 'song' ? owner : undefined;
}
export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean {
return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false);
}
/** "Song › Take 1" for an embedded asset, its own name for any other. */
export function assetTitle(project: Project, asset: Asset): string {
const song = ownerSong(project, asset);
return song ? `${song.name} › ${asset.name}` : asset.name;
}
/** 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 };
}
/** A place in a song that names an asset, in the order the places claim what they name. */
interface Slot {
id: string;
types: readonly AssetType[];
set(id: string): void;
}
function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem[]): Slot[] {
const slots: Slot[] = [
{ id: assetFxOf(props), types: ['soundfx'], set: (id) => (props[ASSET_FX_KEY] = id) },
];
for (const track of tracks) slots.push({ id: track.fx, types: ['soundfx'], set: (id) => (track.fx = id) });
for (const item of items) {
slots.push({ id: item.asset, types: [item.kind], set: (id) => (item.asset = id) });
slots.push({ id: item.fx, types: ['soundfx'], set: (id) => (item.fx = id) });
}
return slots;
}
/** The embedded assets the places of these items name, wherever they belong: what a copy of them takes along. */
export function embeddedUsedBy(project: Project, items: readonly SongItem[]): Asset[] {
const ids = new Set(items.flatMap((i) => [i.asset, i.fx]));
return project.assets.filter((a) => isEmbedded(a) && ids.has(a.id));
}
export interface Settled {
/** Copies made for a place whose asset another place claimed first, or that belongs elsewhere. */
added: Asset[];
/** The song's own assets nothing names any more. */
dropped: string[];
}
/**
* Holds the song to one user per embedded asset, writing the places in `tracks` and `items` (fresh
* lists the caller owns): a place naming an asset of the song that an earlier place claimed, or one of
* another song, gets a copy; one naming an asset that is gone but in `spare` – what was copied along
* with items – gets that back. The song's own props are only read: they claim, never copy.
*/
export function settleEmbedded(
project: Project,
song: SongAsset,
tracks: SongTrack[],
items: SongItem[],
spare: readonly Asset[] = [],
): Settled {
const claimed = new Set<string>();
const added: Asset[] = [];
const taken = new Set(project.assets.map((a) => a.id));
const own = { ...song.props };
slotsOf(own, tracks, items).forEach((slot, index) => {
if (slot.id === '' || slot.id === NO_FX) return;
const found = findAsset(project, slot.id) ?? added.find((a) => a.id === slot.id);
const source = found ?? spare.find((a) => a.id === slot.id);
if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return;
if (found?.owner === song.id && !claimed.has(found.id)) {
claimed.add(found.id);
return;
}
if (index === 0) return;
const id = found || taken.has(source.id) ? newId() : source.id;
const copy = { ...structuredClone(source), id, owner: song.id };
taken.add(id);
added.push(copy);
claimed.add(id);
slot.set(id);
});
const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id);
return { added, dropped };
}
+3 -3
View File
@@ -95,9 +95,9 @@ export function wavBytes(parts: readonly Int16Array[], sampleRate: number): Uint
return bytes;
}
/** "Song – Take 1", 2, 3 …: the first number no asset of the project has. */
export function takeName(songName: string, assetNames: readonly string[]): string {
return nextNumberedName(assetNames, `${songName} – Take`);
/** "Take 1", 2, 3 …: the first number none of what the song keeps to itself has (#355). */
export function takeName(embeddedNames: readonly string[]): string {
return nextNumberedName(embeddedNames, 'Take');
}
/** Where the take's own track goes: right under the track clicked last, else at the bottom. */
+4 -2
View File
@@ -1,6 +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 { NO_FX } from './assetFx';
import { MASTER_BUS } from './buses';
@@ -19,12 +20,13 @@ export function assetRefLabel(project: Project | null, id: string): string {
/**
* The assets an `asset` property offers, in menu order; without `assetTypes` every placeable one.
* Sorted by name so the order does not depend on creation time.
* Sorted by name so the order does not depend on creation time. What a song keeps to itself (#355)
* is offered nowhere.
*/
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
const allowed = (asset: Asset): boolean =>
assetTypes ? assetTypes.includes(asset.type) : isPlaceableType(asset.type);
return project.assets
return listedAssets(project)
.filter(allowed)
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
}
+6
View File
@@ -22,6 +22,7 @@ import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing';
import { isSongRoutingDef } from '../music/routingNodes';
@@ -302,7 +303,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
} else {
assets.push({ id, type, name, props });
}
const owner = typeof a.owner === 'string' ? a.owner : '';
if (owner && isEmbeddable(type)) assets[assets.length - 1]!.owner = owner;
}
// One whose song is gone is kept as a project asset rather than lost (#355).
const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id));
for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) delete asset.owner;
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
+3 -1
View File
@@ -1,6 +1,7 @@
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import { assetTitle } from '../core/music/embedded';
import { mixOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
import type { ProjectStore } from '../core/store/ProjectStore';
@@ -877,7 +878,8 @@ export class Editor implements PlayHost {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
return asset ? assetTitle(this.state.project, asset) : '';
}
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
+6
View File
@@ -27,6 +27,7 @@ import { isTemplateContent } from '../core/model/instance';
import type { Command } from '../core/commands/Command';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { ownerSong } from '../core/music/embedded';
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
import { textureRefIds } from '../core/model/texture';
@@ -234,6 +235,11 @@ export function removeSelection(state: EditorState): boolean {
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const asset = findAsset(state.project, assetId);
if (!asset) return;
const song = ownerSong(state.project, asset);
if (song) {
state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`);
return;
}
const usages = findUsages(state.project, assetId).length;
// A texture is never placed but worn through a property, so counting placed instances would
// report "unused". Its frames count as the texture itself.
+5 -2
View File
@@ -41,6 +41,7 @@ import {
} from '../../core/commands/structTypeCommands';
import { containsType, type FieldRename, structAssetOf } from '../../core/model/structTypes';
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 { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
@@ -93,7 +94,9 @@ export function assetSignature(project: Project, assetId: string): string {
// Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure.
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
const values = resolveAssetProps(project, asset);
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values));
const rows = schemaForAsset(project, asset).filter(
(def) => isRowShown(def, values) && !isRowHiddenWhenEmbedded(asset, def.key),
);
// A Type row shows the name of its Struct asset, or "Own fields" once that asset is gone (#253).
const types = `|types:${typeNames(project, rows)}`;
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes + types;
@@ -202,7 +205,7 @@ const isSwitch = (key: string): boolean =>
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void {
const project = () => host.state.project;
const values = resolveAssetProps(project(), asset);
const shown = schema.filter((d) => isRowShown(d, values));
const shown = schema.filter((d) => isRowShown(d, values) && !isRowHiddenWhenEmbedded(asset, d.key));
if (shown.length > 0) addTitle(host, title);
for (const def of shown) {
const row = createFieldRow(
+12 -7
View File
@@ -17,6 +17,7 @@ import { generatesPictures } from '../../core/model/generated';
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { listedAssets, ownerSong } from '../../core/music/embedded';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import { schemaForAsset } from '../../core/properties/PropertySchema';
@@ -112,10 +113,14 @@ export class AssetPanel extends Panel {
this.refresh();
}
/** Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. */
/**
* Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a
* song keeps to itself has no row, so its song lights up (#355).
*/
private ping(): void {
const id = this.state.pingedAssetId;
if (!id || !findAsset(this.state.project, id)) return;
const asset = findAsset(this.state.project, this.state.pingedAssetId ?? '');
if (!asset) return;
const id = ownerSong(this.state.project, asset)?.id ?? asset.id;
this.reveal(id);
this.list.ping(id);
}
@@ -153,7 +158,7 @@ export class AssetPanel extends Panel {
private refresh(): void {
const project = this.state.project;
const items: ListItem[] = [];
for (const { category, groups } of groupAssets(project.assets)) {
for (const { category, groups } of groupAssets(listedAssets(project))) {
const open = !this.foldedCategories.has(category);
items.push({
id: categoryId(category),
@@ -249,7 +254,7 @@ export class AssetPanel extends Panel {
expanded: boolean;
ids: string[];
}[] {
return groupAssets(this.state.project.assets).flatMap(({ category, groups }) =>
return groupAssets(listedAssets(this.state.project)).flatMap(({ category, groups }) =>
groups.map(({ type, assets }) => ({
category,
type,
@@ -262,7 +267,7 @@ export class AssetPanel extends Panel {
/** The categories in order, and whether each is unfolded (test hook). */
categories(): { category: AssetCategory; expanded: boolean }[] {
return groupAssets(this.state.project.assets).map(({ category }) => ({
return groupAssets(listedAssets(this.state.project)).map(({ category }) => ({
category,
expanded: !this.foldedCategories.has(category),
}));
@@ -319,7 +324,7 @@ export class AssetPanel extends Panel {
*/
private createPixelArt(): void {
const project = this.state.project;
const existing = groupAssets(project.assets)
const existing = groupAssets(listedAssets(project))
.flatMap((category) => category.groups)
.find((group) => group.type === 'palette')?.assets[0];
const palette = existing
+11 -8
View File
@@ -1,5 +1,6 @@
import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import { SetSongCommand } from '../../core/commands/songCommands';
import {
newItem,
@@ -10,6 +11,7 @@ import {
songSequence,
songTempo,
} from '../../core/music/song';
import { embeddedIn } from '../../core/music/embedded';
import { roundAt } from '../../core/music/ghosts';
import { patternLength } from '../../core/music/pattern';
import { fxVoiceOf } from '../../core/music/needs';
@@ -358,11 +360,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
this.state.setNotice('Nothing was recorded');
return;
}
const song = findAssetOf(this.state.project, take.songId, 'song');
const name = takeName(
song?.name ?? 'Recording',
this.state.project.assets.map((a) => a.name),
);
const name = takeName(embeddedIn(this.state.project, take.songId).map((a) => a.name));
const seconds = sampleCount(parts) / take.recorder.sampleRate;
await this.upload(take, wavBytes(parts, take.recorder.sampleRate), name, seconds);
}
@@ -410,14 +408,19 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
this.syncPlayButton();
}
/** The sound, its track and its item, one undo step; the song gone meanwhile, only the sound. */
/**
* The sound, its track and its item, one undo step. The sound is the song's own (#355); with the
* song gone meanwhile, it is a project asset by itself.
*/
private place(take: Take, file: string, name: string, seconds: number): void {
const create = new CreateSoundCommand(name, file);
const song = findAssetOf(this.state.project, take.songId, 'song');
if (!song) {
if (this.state.execute(create)) this.state.setNotice(`Recorded "${name}" – its song is gone`);
const create = new CreateSoundCommand(nextAssetName(this.state.project, name), file);
if (this.state.execute(create))
this.state.setNotice(`Recorded "${create.asset.name}" – its song is gone`);
return;
}
const create = new CreateSoundCommand(name, file, song.id);
const track = takeTrack();
const tracks = [...song.tracks];
tracks.splice(Math.min(take.index, tracks.length), 0, track);
+44 -8
View File
@@ -5,7 +5,9 @@ import {
Graphics,
type Text,
} from 'pixi.js';
import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands';
import { MakeProjectAssetCommand } from '../../core/commands/assetCommands';
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 type { LoopRange } from '../../core/music/sequence';
import {
@@ -24,7 +26,7 @@ import {
TRACK_MIN_DB,
} from '../../core/music/song';
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
import type { SongAsset } from '../../core/model/Asset';
import type { Asset, SongAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf } from '../../core/model/Project';
import {
@@ -116,6 +118,8 @@ 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;
@@ -222,6 +226,8 @@ export class SongTimeline extends Widget {
private startTick = 0;
/** What Ctrl+C took, for any song of the project. */
private clipboard: SongItem[] = [];
/** What the song kept to itself of the copied items (#355), as it was then: a paste still has it after a delete. */
private clipboardAssets: Asset[] = [];
/** Sounds asked for, so a waveform is drawn once its bytes are there. */
private readonly loading = new Set<string>();
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
@@ -367,6 +373,7 @@ export class SongTimeline extends Widget {
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i }));
this.clipboardAssets = structuredClone(embeddedUsedBy(this.state.project, this.clipboard));
const n = this.clipboard.length;
this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet');
return true;
@@ -389,7 +396,7 @@ export class SongTimeline extends Widget {
);
this.commit(
asset,
{ items: [...asset.items, ...copies] },
{ items: [...asset.items, ...copies], spare: this.clipboardAssets },
copies.length === 1 ? 'Paste item' : 'Paste items',
);
this.pick(copies.map((i) => i.id));
@@ -987,7 +994,7 @@ export class SongTimeline extends Widget {
this.invalidate();
}
private commit(asset: SongAsset, parts: { tracks?: SongTrack[]; items?: SongItem[] }, label: string): void {
private commit(asset: SongAsset, parts: SongParts, label: string): void {
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
}
@@ -1010,10 +1017,36 @@ export class SongTimeline extends Widget {
/** The menu of the item under a right-click, picked first unless it is already. */
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
const hit = itemAt(this.view, asset.items, asset.tracks, g);
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 (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
const played = findAsset(this.state.project, hit.asset);
const embedded = played !== undefined && isEmbedded(played);
new Menu([
{ label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) },
embedded
? {
label: 'Make project asset',
onSelect: () => this.state.execute(new MakeProjectAssetCommand(hit.asset)),
}
: { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) },
{ separator: true },
{ label: 'Delete', onSelect: () => this.deleteSelection() },
]).open(global.x, global.y);
}
/** 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);
this.commit(
asset,
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] },
'Embed a copy',
);
return true;
}
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
@@ -1024,7 +1057,7 @@ export class SongTimeline extends Widget {
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
'Set track sound FX',
);
const effects = this.state.project.assets
const effects = listedAssets(this.state.project)
.filter((a) => a.type === 'soundfx')
.sort((a, b) => a.name.localeCompare(b.name));
new Menu([
@@ -1162,8 +1195,11 @@ export class SongTimeline extends Widget {
return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : [];
}
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
private labelOf(item: SongItem): string {
return findAsset(this.state.project, item.asset)?.name ?? 'missing';
const asset = findAsset(this.state.project, item.asset);
if (!asset) return 'missing';
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
}
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
+3 -2
View File
@@ -6,6 +6,7 @@ import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { listedAssets } from '../../core/music/embedded';
import { playedBus } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
@@ -75,7 +76,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
/** The sound the header plays: the one picked, else the project's first. */
get previewSoundId(): string {
const sounds = this.state.project.assets.filter((a) => a.type === 'sound');
const sounds = listedAssets(this.state.project).filter((a) => a.type === 'sound');
return sounds.some((a) => a.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
}
@@ -183,7 +184,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
private openSoundPicker(): void {
const current = this.previewSoundId;
const items = this.state.project.assets
const items = listedAssets(this.state.project)
.filter((a) => a.type === 'sound')
.map((a) => ({ label: a.name, current: a.id === current, onSelect: () => this.pickSound(a.id) }));
if (items.length > 0) new Menu(items).openBelow(this.soundPicker);
+213
View File
@@ -0,0 +1,213 @@
/**
* What a song keeps to itself (#355): an asset with the song as its owner, left out of every list,
* with one user in the song. Split, duplicate and paste give a place a copy of its own; what nothing
* names goes in the same step, and comes back with an undo.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
DeleteAssetCommand,
DuplicateAssetCommand,
MakeProjectAssetCommand,
} 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 { findAsset, findAssetOf } from '../../src/core/model/Project';
import {
assetTitle,
embeddedCopy,
embeddedIn,
embeddedUsedBy,
isRowHiddenWhenEmbedded,
listedAssets,
} from '../../src/core/music/embedded';
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';
import { makeProject } from '../helpers';
const FILE = `${'c4'.repeat(32)}.wav`;
const BAR = 1920;
/** A song "Theme" with a take of its own on an item, and a project sound "Blip". */
function setup() {
const f = makeProject();
const made = new CreateAssetCommand('song', 'Theme');
f.stack.execute(made);
const songId = made.asset.id;
const song = () => findAssetOf(f.project, songId, 'song')!;
const blip = new CreateSoundCommand('Blip', FILE);
f.stack.execute(blip);
const take = new CreateSoundCommand('Take 1', FILE, songId);
f.stack.execute(take);
const track = song().tracks[0]!.id;
const item = newItem({ track, kind: 'sound', asset: take.asset.id, tick: 0, length: 2 * BAR });
f.stack.execute(new SetSongCommand(songId, { items: [item] }));
const edit = (items: SongItem[], extra: { spare?: Asset[] } = {}) =>
f.stack.execute(new SetSongCommand(songId, { items, ...extra }));
return { f, songId, song, track, blipId: blip.asset.id, takeId: take.asset.id, itemId: item.id, edit };
}
const ids = (assets: readonly Asset[]) => assets.map((a) => a.id);
describe('an embedded asset (#355)', () => {
it('is found by its id but left out of every list and of the names', () => {
const { f, songId, takeId, blipId, song } = setup();
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
expect(ids(listedAssets(f.project))).not.toContain(takeId);
expect(ids(assetRefChoices(f.project, ['sound']))).toEqual([blipId]);
expect(nextAssetName(f.project, 'Take 1')).toBe('Take 1');
expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeId]);
expect(assetTitle(f.project, findAsset(f.project, takeId)!)).toBe('Theme › Take 1');
});
it('hides the rows that do nothing in a song, only while embedded', () => {
const { f, takeId, blipId } = setup();
const take = findAsset(f.project, takeId)!;
expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
true,
true,
false,
false,
]);
expect(isRowHiddenWhenEmbedded(findAsset(f.project, blipId)!, 'loop')).toBe(false);
});
it('gives the right half of a split a copy of its own, the same one again on redo', () => {
const { f, song, takeId, edit } = setup();
const left = song().items[0]!;
edit([
{ ...left, length: BAR },
{ ...left, id: 'right', tick: BAR, length: BAR },
]);
const [a, b] = song().items;
expect(a!.asset).toBe(takeId);
expect(b!.asset).not.toBe(takeId);
const copy = findAsset(f.project, b!.asset)!;
expect([copy.owner, copy.name, copy.type]).toEqual([song().id, 'Take 1', 'sound']);
f.stack.undo();
expect(findAsset(f.project, copy.id)).toBeUndefined();
expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]);
f.stack.redo();
expect(song().items[1]!.asset).toBe(copy.id);
expect(findAsset(f.project, copy.id)).toBe(copy);
});
it('goes with the item that names it, and comes back on undo where it was', () => {
const { f, songId, takeId, edit } = setup();
const at = f.project.assets.findIndex((a) => a.id === takeId);
edit([]);
expect(findAsset(f.project, takeId)).toBeUndefined();
f.stack.undo();
expect(f.project.assets.findIndex((a) => a.id === takeId)).toBe(at);
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
});
it('comes back from what was copied along when a paste names it after it went', () => {
const { f, song, takeId, edit } = setup();
const copied = song().items.map((i) => ({ ...i }));
const spare = structuredClone(embeddedUsedBy(f.project, copied));
expect(ids(spare)).toEqual([takeId]);
edit([]);
edit(
copied.map((i) => ({ ...i, id: 'pasted' })),
{ spare },
);
expect(song().items[0]!.asset).toBe(takeId);
expect(findAsset(f.project, takeId)?.owner).toBe(song().id);
});
it('is copied for a song that names one of another song', () => {
const { f, song, takeId } = setup();
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const otherSong = other.asset as SongAsset;
const item = newItem({
track: otherSong.tracks[0]!.id,
kind: 'sound',
asset: takeId,
tick: 0,
length: BAR,
});
f.stack.execute(new SetSongCommand(otherSong.id, { items: [item] }));
const named = findAssetOf(f.project, otherSong.id, 'song')!.items[0]!.asset;
expect(named).not.toBe(takeId);
expect(findAsset(f.project, named)?.owner).toBe(otherSong.id);
expect(findAsset(f.project, takeId)?.owner).toBe(song().id);
});
it('leaves a project asset named by several items alone', () => {
const { f, song, blipId, edit, track } = setup();
const before = f.project.assets.length;
edit([
newItem({ track, kind: 'sound', asset: blipId, tick: 0, length: BAR }),
newItem({ track, kind: 'sound', asset: blipId, tick: BAR, length: BAR }),
]);
expect(song().items.map((i) => i.asset)).toEqual([blipId, blipId]);
expect(f.project.assets.length).toBe(before - 1);
});
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);
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);
expect(findAsset(f.project, blipId)?.owner).toBeUndefined();
});
it('goes with its song, and comes back with it', () => {
const { f, songId, takeId } = setup();
f.stack.execute(new DeleteAssetCommand(songId));
expect(findAsset(f.project, takeId)).toBeUndefined();
f.stack.undo();
expect(findAsset(f.project, takeId)?.owner).toBe(songId);
expect(findAsset(f.project, songId)).toBeDefined();
});
it('is copied for a duplicate of its song, which names the copy', () => {
const { f, songId, takeId } = setup();
const dup = new DuplicateAssetCommand(songId);
f.stack.execute(dup);
const copy = findAssetOf(f.project, dup.copyId, 'song')!;
const named = copy.items[0]!.asset;
expect(named).not.toBe(takeId);
expect(findAsset(f.project, named)?.owner).toBe(copy.id);
expect(embeddedIn(f.project, songId).map((a) => a.id)).toEqual([takeId]);
f.stack.undo();
expect(findAsset(f.project, named)).toBeUndefined();
});
it('becomes a project asset of a free name, and back on undo', () => {
const { f, songId, takeId } = setup();
f.stack.execute(new CreateSoundCommand('Take 1', FILE));
f.stack.execute(new MakeProjectAssetCommand(takeId));
const take = findAsset(f.project, takeId)!;
expect([take.owner, take.name]).toEqual([undefined, 'Take 1 2']);
expect(ids(listedAssets(f.project))).toContain(takeId);
f.stack.undo();
expect([take.owner, take.name]).toEqual([songId, 'Take 1']);
});
it('a duplicate of it is a project asset', () => {
const { f, takeId } = setup();
const dup = new DuplicateAssetCommand(takeId);
f.stack.execute(dup);
expect(findAsset(f.project, dup.copyId)?.owner).toBeUndefined();
});
it('keeps its owner through a save, and is a project asset once its song is gone', () => {
const { f, songId, takeId } = setup();
const raw = serializeProject(f.project) as { assets: Record<string, unknown>[] };
expect(deserializeProject(raw).assets.find((a) => a.id === takeId)?.owner).toBe(songId);
const orphan = { ...raw, assets: raw.assets.filter((a) => a.id !== songId) };
expect(deserializeProject(orphan).assets.find((a) => a.id === takeId)?.owner).toBeUndefined();
const sprite = {
...raw,
assets: [...raw.assets, { id: 'sp', type: 'sprite', name: 'S', props: {}, owner: songId }],
};
expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined();
});
});
+4 -3
View File
@@ -68,9 +68,10 @@ describe('a recorded take (#342)', () => {
expect([view.getInt16(44, true), view.getInt16(46, true), view.getInt16(48, true)]).toEqual([1, -2, 300]);
});
it('is named after its song, numbered past the takes there are', () => {
expect(takeName('Theme', ['Theme'])).toBe('Theme – Take 1');
expect(takeName('Theme', ['Theme', 'Theme – Take 1', 'Theme – Take 2'])).toBe('Theme – Take 3');
it('is numbered past the takes its song keeps (#355)', () => {
expect(takeName([])).toBe('Take 1');
expect(takeName(['Take 1', 'Take 2', 'Intro'])).toBe('Take 3');
expect(takeName(['Take 2'])).toBe('Take 1');
});
it('gets a track of its own under the one clicked last, else at the bottom', () => {
+22 -3
View File
@@ -695,19 +695,21 @@ export async function song(t) {
);
const takeTrack = kept.tracks[1];
const takeItem = kept.items.find((i) => i.track === takeTrack?.id);
const takeId = JSON.stringify(takeItem?.asset ?? '');
const takeAsset = await json(
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : null; })()`,
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${takeId}); return a ? { name: a.name, file: a.file, type: a.type, owner: a.owner } : null; })()`,
);
const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`);
const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0;
check(
'Space keeps it: a WAV sound named after the song, on the track "Recording", from the cursor, as long as it is',
'Space keeps it: a WAV sound of the song\'s own, "Take 1", on the track "Recording", from the cursor, as long as it is',
kept.tracks[0]?.id === before.tracks[0]?.id &&
takeTrack?.name === 'Recording' &&
takeItem?.kind === 'sound' &&
takeItem.tick === BAR &&
takeAsset?.type === 'sound' &&
takeAsset.name === `${songName} – Take 1` &&
takeAsset.name === 'Take 1' &&
takeAsset.owner === id &&
/^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(takeAsset.file) &&
takeSeconds > 1 &&
Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 &&
@@ -715,6 +717,23 @@ export async function song(t) {
(await json('__gm.song.view()')).take === null,
JSON.stringify({ kept, takeAsset, takeSeconds }),
);
// #355: the take is the song's own – no row in the asset panel, a mark on its item, its song in the tab title.
const takeRow = await json(`__gm.assetRow(${takeId})`);
const takeLabel = (await json('__gm.song.items()')).find((i) => i.id === takeItem?.id)?.label;
p = await json(`__gm.song.itemAt(${JSON.stringify(takeItem?.id ?? '')}, 'body')`);
await doubleClick(p.x, p.y);
await sleep(200);
const takeTab = await json(`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${takeId}] }`);
check(
'the take lives in the song: no row in the asset panel, • on its item, a double-click opens "Song › Take 1"',
takeRow === null &&
takeLabel === '• Take 1' &&
takeTab.tab === 'sound' &&
takeTab.title === `${songName} › Take 1`,
JSON.stringify({ takeRow, takeLabel, takeTab }),
);
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
await sleep(200);
await press('z', { ctrl: true });
const undone = await model();
check(