Files
feuerwerk/src/core/music/projectRouting.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

241 lines
10 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.
/**
* 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;
}