Files
feuerwerk/src/core/music/embedded.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00

210 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. The places of its song may share it (#357): the song itself, its
* tracks and its items. `settleEmbedded` holds a song to that after every edit, so a paste from another
* song gets a copy of its own and what nothing uses any more goes.
*/
import type { Graph } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
import { newId } from '../ids';
import type { Asset, SongAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag } from '../model/Properties';
import { findAsset, findAssetOf, type Project } from '../model/Project';
import { assetNamedBy, renewClipIds } from '../model/sound';
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
import { nextNumberedName } from '../commands/naming';
import { PATTERN_SIGN_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
import { type SongItem, songMeter, songTempo, type SongTrack } from './song';
import { signOf } from './time';
export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx'];
/**
* The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern
* keeps its tempo and meter (#363), as one of the project does.
*/
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
sound: ['loop'],
};
export function isEmbeddable(type: AssetType): boolean {
return EMBEDDABLE_TYPES.includes(type);
}
export function isEmbedded(asset: Asset): boolean {
return (asset.owner ?? '') !== '';
}
/** In front of the name of what the song keeps to itself, wherever it is named. */
export const EMBEDDED_MARK = '•';
export function markedName(asset: Asset): string {
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
}
/** 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;
}
/** The tempo and the meter of a song, as the props a pattern made in it starts with. */
export function songClockProps(props: PropertyBag): PropertyBag {
return { [PATTERN_TEMPO_KEY]: songTempo(props), [PATTERN_SIGN_KEY]: signOf(songMeter(props)) };
}
/**
* A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest.
* `ids` says what the asset and its clips (#357) are called in the copy.
*/
export function embeddedCopy(asset: Asset, song: SongAsset): { copy: Asset; ids: Map<string, string> } {
const copy: Asset = { ...structuredClone(asset), id: newId(), owner: song.id };
const ids = new Map([[asset.id, copy.id]]);
renewClipIds(copy, ids);
return { copy, ids };
}
/** "Pattern 1", 2, 3 … ("Sound FX 1" …): the first number none of what the song keeps to itself has. */
export function embeddedName(project: Project, songId: string, base: string): string {
return nextNumberedName(
embeddedIn(project, songId).map((a) => a.name),
base,
);
}
/** What a sound FX made in a song starts as (#355): the Input wired to the Output, so it lets the sound through. */
export function passThroughFxGraph(): Graph {
const input = newId();
const output = newId();
return {
nodes: [
{ id: input, type: SOUND_FX_INPUT, x: 0, y: 0, params: {} },
{ id: output, type: SOUND_FX_OUTPUT, x: 260, y: 0, params: {} },
],
edges: [{ id: newId(), from: { node: input, port: 'out' }, to: { node: output, port: 'in' } }],
};
}
/**
* 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. */
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 named = new Set(
items.flatMap((i) => [i.asset, i.fx]).map((id) => assetNamedBy(project.assets, id)?.id),
);
return project.assets.filter((a) => isEmbedded(a) && named.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 what it keeps to itself, writing the places in `tracks` and `items` (fresh lists
* the caller owns). The places of the song share its assets (#357), so the clips of one take serve
* many items. A place naming an asset of another song gets a copy, one for all places naming it; 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 copies = new Map<string, Map<string, string>>();
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;
// A place may name a clip of a sound (#357): the sound is what it claims.
const found = assetNamedBy(project.assets, slot.id) ?? assetNamedBy(added, slot.id);
const source = found ?? assetNamedBy(spare, slot.id);
if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return;
if (found?.owner === song.id) {
claimed.add(found.id);
return;
}
if (index === 0) return;
let ids = copies.get(source.id);
if (!ids) {
const fresh = found !== undefined || taken.has(source.id);
const id = fresh ? newId() : source.id;
const copy: Asset = { ...structuredClone(source), id, owner: song.id };
ids = new Map([[source.id, id]]);
if (fresh) renewClipIds(copy, ids);
copies.set(source.id, ids);
taken.add(id);
added.push(copy);
claimed.add(id);
}
slot.set(ids.get(slot.id) ?? ids.get(source.id) ?? slot.id);
});
const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id);
return { added, dropped };
}