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

+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);