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>
210 lines
8.5 KiB
TypeScript
210 lines
8.5 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|