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>
131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
/**
|
||
* Editing a song (#334). As with the notes of a pattern, one gesture is one command: the tracks or
|
||
* items are written whole once the pointer lets go, so an undo takes the drag back. What is written
|
||
* is held to the rules of a track and an item first; an item whose track goes goes with it, and the
|
||
* routing follows the tracks (#350).
|
||
*/
|
||
import type { EdgeSend, Graph } from '../graph/Graph';
|
||
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,
|
||
private readonly parts: SongParts,
|
||
readonly label = 'Edit song',
|
||
) {}
|
||
|
||
/** Nothing to do when the song already reads exactly like this – no empty undo step. */
|
||
static changes(asset: SongAsset, parts: SongParts): boolean {
|
||
const tracks = heldSongTracks(parts.tracks ?? asset.tracks);
|
||
const items = heldSongItems(parts.items ?? asset.items, tracks);
|
||
return JSON.stringify([asset.tracks, asset.items]) !== JSON.stringify([tracks, items]);
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireSong(project, this.assetId);
|
||
this.previous = { tracks: asset.tracks, items: asset.items, graph: asset.graph };
|
||
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;
|
||
asset.graph = this.previous.graph;
|
||
}
|
||
}
|
||
|
||
/** How loud a link of a song's routing sends, and where it taps its track (#350). */
|
||
export class SetSendCommand implements Command {
|
||
readonly label = 'Set send';
|
||
private previous: EdgeSend | undefined;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
private readonly send: EdgeSend,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
requireSong(project, this.assetId);
|
||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||
this.previous = edge.send;
|
||
const held = heldSend(this.send);
|
||
if (held) edge.send = held;
|
||
else delete edge.send;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||
if (this.previous) edge.send = this.previous;
|
||
else delete edge.send;
|
||
}
|
||
}
|
||
|
||
/** Sets the loop of the ruler, or takes it away (`null`): then the whole song is the loop. */
|
||
export class SetSongLoopCommand implements Command {
|
||
readonly label: string;
|
||
private previous: LoopRange | undefined;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly loop: LoopRange | null,
|
||
) {
|
||
this.label = loop ? 'Set loop' : 'Loop the whole song';
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireSong(project, this.assetId);
|
||
this.previous = asset.loop;
|
||
const held = heldLoop(this.loop);
|
||
if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) };
|
||
else delete asset.loop;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const asset = requireSong(project, this.assetId);
|
||
if (this.previous) asset.loop = this.previous;
|
||
else delete asset.loop;
|
||
}
|
||
}
|