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>
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
/**
|
||
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song and
|
||
* bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one
|
||
* that comes gets a node of its own and a link to the master, one that goes takes its node along.
|
||
*/
|
||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, reaches } from '../graph/Graph';
|
||
import { newId } from '../ids';
|
||
import type { Asset } from '../model/Asset';
|
||
import type { AssetType } from '../model/AssetTypes';
|
||
import { textOf } from '../model/Properties';
|
||
import type { Project } from '../model/Project';
|
||
import { MASTER_BUS } from '../properties/buses';
|
||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||
import { isEmbedded } from './embedded';
|
||
import { heldSend } from './routing';
|
||
import { ROUTED_ASSET, ROUTED_KIND, ROUTED_MASTER, ROUTED_NAME } from './routingNodes';
|
||
|
||
export const ROUTED_TYPES: readonly AssetType[] = ['sound', 'instrument', 'pattern', 'song', 'bus'];
|
||
/** What has its own sound FX and level on a bus of its own, and so may send from before them. */
|
||
const TAPPED_TYPES: readonly AssetType[] = ['bus', 'song'];
|
||
|
||
/**
|
||
* Where a new node goes: what plays in the first column, by type, the buses in the second, the
|
||
* master in the third a row above the rest, so no link into it runs behind a bus.
|
||
*/
|
||
const COLUMN_STEP = 320;
|
||
const ROW_STEP = 80;
|
||
const COLUMN_OF = (type: string) => (type === 'bus' ? 1 : 0);
|
||
const MASTER_COLUMN = 2;
|
||
|
||
export function isRouted(asset: Asset): boolean {
|
||
return ROUTED_TYPES.includes(asset.type) && !isEmbedded(asset);
|
||
}
|
||
|
||
/** Whether links out of an asset of this type may take the sound before its sound FX or its level. */
|
||
export function sendsTapped(type: string): boolean {
|
||
return (TAPPED_TYPES as readonly string[]).includes(type);
|
||
}
|
||
|
||
/** The asset type a node of the routing stands for, `''` for the master. */
|
||
export function routedKind(node: GraphNode): string {
|
||
return node.type === ROUTED_ASSET ? textOf(node.params[ROUTED_KIND] ?? '') : '';
|
||
}
|
||
|
||
/**
|
||
* The routing as it has to be for the assets of the project: a node for each, named after it, and
|
||
* the master. An asset without a node gets one below the others of its column and a link to the master
|
||
* – every asset of a project written before there was a routing. A link goes where one of its ends
|
||
* is gone, where it would close a loop or say again what another one says; a node whose every link
|
||
* led to a bus that is gone is linked to the master instead, so deleting a bus silences nothing.
|
||
* Without `adding`, as a project is read, an asset without a node stays without one – it is heard on
|
||
* the master all the same (`routeOf`) – and the master is added only to a routing that has nodes.
|
||
*/
|
||
export function heldRouting(graph: Graph | undefined, assets: readonly Asset[], adding = true): Graph {
|
||
const given = graph ?? createEmptyGraph();
|
||
const routed = new Map(assets.filter(isRouted).map((asset) => [asset.id, asset]));
|
||
const paramsOf = (asset: Asset) => ({ [ROUTED_NAME]: asset.name, [ROUTED_KIND]: asset.type });
|
||
// What stands keeps its place in the list, so a project reads back as it was written.
|
||
const nodes: GraphNode[] = given.nodes.flatMap((node) => {
|
||
if (node.id === MASTER_BUS) return node.type === ROUTED_MASTER ? [node] : [];
|
||
const asset = routed.get(node.id);
|
||
return asset && routedKind(node) === asset.type
|
||
? [{ ...node, params: { ...node.params, ...paramsOf(asset) } }]
|
||
: [];
|
||
});
|
||
const placed = new Set(nodes.map((node) => node.id));
|
||
const lowest = new Map<number, number>();
|
||
for (const node of nodes) {
|
||
const column = COLUMN_OF(routedKind(node));
|
||
if (node.type === ROUTED_ASSET) lowest.set(column, Math.max(lowest.get(column) ?? -Infinity, node.y));
|
||
}
|
||
const fresh = new Set<string>();
|
||
const order = (asset: Asset) => ROUTED_TYPES.indexOf(asset.type);
|
||
if (adding)
|
||
for (const asset of [...routed.values()].sort((a, b) => order(a) - order(b))) {
|
||
if (placed.has(asset.id)) continue;
|
||
fresh.add(asset.id);
|
||
const column = COLUMN_OF(asset.type);
|
||
const y = (lowest.get(column) ?? -ROW_STEP) + ROW_STEP;
|
||
lowest.set(column, y);
|
||
nodes.push({ id: asset.id, type: ROUTED_ASSET, x: column * COLUMN_STEP, y, params: paramsOf(asset) });
|
||
}
|
||
if (!placed.has(MASTER_BUS) && (adding || nodes.length > 0)) {
|
||
const top = Math.min(0, ...nodes.map((node) => node.y));
|
||
nodes.push({
|
||
id: MASTER_BUS,
|
||
type: ROUTED_MASTER,
|
||
x: MASTER_COLUMN * COLUMN_STEP,
|
||
y: top - ROW_STEP,
|
||
params: {},
|
||
});
|
||
}
|
||
|
||
const kinds = new Map(nodes.map((node) => [node.id, routedKind(node)]));
|
||
const takesIn = (id: string) => id === MASTER_BUS || kinds.get(id) === 'bus';
|
||
const edges: GraphEdge[] = [];
|
||
const edgeIds = new Set<string>();
|
||
const lost = new Set<string>();
|
||
for (const edge of given.edges) {
|
||
const from = edge.from.node;
|
||
const to = edge.to.node;
|
||
const kind = kinds.get(from);
|
||
if (!kind || fresh.has(from) || edge.from.port !== 'out') continue;
|
||
if (!takesIn(to) || edge.to.port !== 'in') {
|
||
lost.add(from);
|
||
continue;
|
||
}
|
||
if (from === to || edgeIds.has(edge.id)) continue;
|
||
if (edges.some((e) => e.from.node === from && e.to.node === to)) continue;
|
||
if (to !== MASTER_BUS && reaches({ edges }, to, from)) continue;
|
||
edgeIds.add(edge.id);
|
||
const raw = heldSend(edge.send);
|
||
const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw;
|
||
edges.push({
|
||
id: edge.id,
|
||
from: { node: from, port: 'out' },
|
||
to: { node: to, port: 'in' },
|
||
...(edge.folded && { folded: true }),
|
||
...(send && { send }),
|
||
});
|
||
}
|
||
const linked = new Set(edges.map((e) => e.from.node));
|
||
for (const node of nodes)
|
||
if (fresh.has(node.id) || (lost.has(node.id) && !linked.has(node.id)))
|
||
edges.push({ id: newId(), from: { node: node.id, port: 'out' }, to: { node: MASTER_BUS, port: 'in' } });
|
||
return {
|
||
nodes,
|
||
edges,
|
||
...(given.groups && { groups: given.groups }),
|
||
...(given.notes && { notes: given.notes }),
|
||
};
|
||
}
|
||
|
||
/** What one settling of the routing changed, by id, so it can be taken back and done again. */
|
||
export interface RoutingChange {
|
||
nodes: { added: GraphNode[]; removed: Removed<GraphNode>[]; renamed: RenamedNode[] };
|
||
edges: { added: GraphEdge[]; removed: Removed<GraphEdge>[] };
|
||
}
|
||
|
||
interface RenamedNode {
|
||
id: string;
|
||
before: GraphNode['params'];
|
||
after: GraphNode['params'];
|
||
}
|
||
|
||
/**
|
||
* Holds the routing of the project to its assets (`heldRouting`), in place: nodes and links stay
|
||
* the objects they are, so what an undo of another edit holds on to still finds them. Null where
|
||
* nothing had to change.
|
||
*/
|
||
export function settleRouting(project: Project): RoutingChange | null {
|
||
const graph = project.routing;
|
||
const held = heldRouting(graph, project.assets);
|
||
const heldNodes = new Map(held.nodes.map((n) => [n.id, n]));
|
||
const heldEdges = new Map(held.edges.map((e) => [e.id, e]));
|
||
const bare = ({ params: _, ...node }: GraphNode) => JSON.stringify(node);
|
||
// A node that is another thing now goes and comes anew; one that only reads another name keeps itself.
|
||
const removedNodes = new Set(
|
||
graph.nodes
|
||
.filter((n) => !heldNodes.has(n.id) || bare(heldNodes.get(n.id)!) !== bare(n))
|
||
.map((n) => n.id),
|
||
);
|
||
const removedEdges = new Set(
|
||
graph.edges.filter((e) => JSON.stringify(heldEdges.get(e.id)) !== JSON.stringify(e)).map((e) => e.id),
|
||
);
|
||
const standing = new Set(graph.nodes.map((n) => n.id).filter((id) => !removedNodes.has(id)));
|
||
const kept = new Set(graph.edges.map((e) => e.id).filter((id) => !removedEdges.has(id)));
|
||
const renamed: RenamedNode[] = graph.nodes.flatMap((node) => {
|
||
const after = heldNodes.get(node.id)?.params;
|
||
return standing.has(node.id) && after && JSON.stringify(after) !== JSON.stringify(node.params)
|
||
? [{ id: node.id, before: node.params, after }]
|
||
: [];
|
||
});
|
||
const change: RoutingChange = {
|
||
nodes: { added: held.nodes.filter((n) => !standing.has(n.id)), removed: [], renamed },
|
||
edges: { added: held.edges.filter((e) => !kept.has(e.id)), removed: [] },
|
||
};
|
||
if (
|
||
removedNodes.size +
|
||
removedEdges.size +
|
||
renamed.length +
|
||
change.nodes.added.length +
|
||
change.edges.added.length ===
|
||
0
|
||
)
|
||
return null;
|
||
applyRouting(project, change, removedNodes, removedEdges);
|
||
return change;
|
||
}
|
||
|
||
function applyRouting(
|
||
project: Project,
|
||
change: RoutingChange,
|
||
removedNodes: ReadonlySet<string>,
|
||
removedEdges: ReadonlySet<string>,
|
||
): void {
|
||
const graph = project.routing;
|
||
change.edges.removed = removeWhere(graph.edges, (e) => removedEdges.has(e.id));
|
||
change.nodes.removed = removeWhere(graph.nodes, (n) => removedNodes.has(n.id));
|
||
for (const { id, after } of change.nodes.renamed) {
|
||
const node = graph.nodes.find((n) => n.id === id);
|
||
if (node) node.params = after;
|
||
}
|
||
graph.nodes.push(...change.nodes.added);
|
||
graph.edges.push(...change.edges.added);
|
||
}
|
||
|
||
/** Does a settling again, as it was done the first time. */
|
||
export function redoRouting(project: Project, change: RoutingChange): void {
|
||
applyRouting(
|
||
project,
|
||
change,
|
||
new Set(change.nodes.removed.map((r) => r.item.id)),
|
||
new Set(change.edges.removed.map((r) => r.item.id)),
|
||
);
|
||
}
|
||
|
||
/** Takes a settling back: what it added goes, what it took comes back where it was. */
|
||
export function undoRouting(project: Project, change: RoutingChange): void {
|
||
const graph = project.routing;
|
||
const addedNodes = new Set(change.nodes.added.map((n) => n.id));
|
||
const addedEdges = new Set(change.edges.added);
|
||
graph.edges.splice(0, graph.edges.length, ...graph.edges.filter((e) => !addedEdges.has(e)));
|
||
graph.nodes.splice(0, graph.nodes.length, ...graph.nodes.filter((n) => !addedNodes.has(n.id)));
|
||
for (const { id, before } of change.nodes.renamed) {
|
||
const node = graph.nodes.find((n) => n.id === id);
|
||
if (node) node.params = before;
|
||
}
|
||
restoreAt(graph.nodes, change.nodes.removed);
|
||
restoreAt(graph.edges, change.edges.removed);
|
||
}
|
||
|
||
/** What is wrong with a node of the routing, by node id: one that goes nowhere is never heard. */
|
||
export function masterRoutingProblems(graph: Graph): Record<string, string> {
|
||
const problems: Record<string, string> = {};
|
||
for (const node of graph.nodes)
|
||
if (node.type === ROUTED_ASSET && !graph.edges.some((e) => e.from.node === node.id))
|
||
problems[node.id] = 'Goes nowhere: link it to a bus or to the master to hear it';
|
||
return problems;
|
||
}
|