/** * The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song, * video with sound, composition 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 the links most others of * its type have, one that goes takes its node along. */ import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } 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 { addLink, heldSend } from './routing'; import { ROUTED_ASSET, ROUTED_KIND, ROUTED_MASTER, ROUTED_NAME } from './routingNodes'; const ROUTED_TYPES: readonly AssetType[] = [ 'sound', 'instrument', 'pattern', 'song', 'video', 'composition', '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; /** A video only where it has sound: one without has nothing to send. */ export function isRouted(asset: Asset): boolean { return ROUTED_TYPES.includes(asset.type) && !isEmbedded(asset) && (asset.type !== 'video' || asset.audio); } /** 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 the links most * others of its type have (`linksLikeTheirs`), else one to the master – so does 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(); 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(); 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 lost = new Set(); for (const edge of given.edges) { const from = edge.from.node; const kind = kinds.get(from); if (!kind || fresh.has(from) || edge.from.port !== 'out') continue; if (!takesIn(edge.to.node) || edge.to.port !== 'in') { lost.add(from); continue; } const raw = heldSend(edge.send); const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw; addLink(edges, edge, send, MASTER_BUS); } const linked = new Set(edges.map((e) => e.from.node)); const toMaster = (from: string): GraphEdge => ({ id: newId(), from: { node: from, port: 'out' }, to: { node: MASTER_BUS, port: 'in' }, }); const added: GraphEdge[] = []; for (const node of nodes) if (fresh.has(node.id)) { const kind = routedKind(node); const like = linksLikeTheirs(kind, nodes, edges); added.push(...(like.length > 0 ? like.map((e) => copiedLink(e, node.id, kind)) : [toMaster(node.id)])); } else if (lost.has(node.id) && !linked.has(node.id)) added.push(toMaster(node.id)); edges.push(...added); return { nodes, edges, ...(given.groups && { groups: given.groups }), ...(given.notes && { notes: given.notes }), }; } /** * The links a new sound, instrument, pattern, song, video or composition takes, so it is heard where * the others are: those of the one whose targets most others of its type share too – the latest of * them on a tie –, or of all that plays where nothing of its type is linked yet. Videos, compositions * and the music go by each other apart: a video where no other is linked goes to the master, not * where the sounds go, and so does a composition, a mix of its own. Empty where nothing is, and for a bus. */ function linksLikeTheirs( kind: string, nodes: readonly GraphNode[], edges: readonly GraphEdge[], ): GraphEdge[] { if (kind === 'bus') return []; const out = new Map(); for (const edge of edges) out.set(edge.from.node, [...(out.get(edge.from.node) ?? []), edge]); const pick = (peers: readonly GraphNode[]): GraphEdge[] => { const counts = new Map(); const targetsOf = (id: string) => (out.get(id) ?? []) .map((e) => e.to.node) .sort() .join(' '); for (const peer of peers) counts.set(targetsOf(peer.id), (counts.get(targetsOf(peer.id)) ?? 0) + 1); let best: GraphNode | null = null; for (const peer of peers) if (!best || counts.get(targetsOf(peer.id))! >= counts.get(targetsOf(best.id))!) best = peer; return best ? out.get(best.id)! : []; }; const playing = nodes.filter((node) => { const of = routedKind(node); return of !== '' && of !== 'bus' && peersOf(of) === peersOf(kind) && out.has(node.id); }); const own = playing.filter((node) => routedKind(node) === kind); return pick(own.length > 0 ? own : playing); } /** Whose links a new node may take: those of its own kind, else of what plays alike. */ function peersOf(kind: string): string { return kind === 'video' || kind === 'composition' ? kind : 'music'; } /** A link like another, out of a new node: as loud, and after the sound FX where it cannot tap before. */ function copiedLink(edge: GraphEdge, from: string, kind: string): GraphEdge { const raw = heldSend(edge.send); const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw; return { id: newId(), from: { node: from, port: 'out' }, to: { node: edge.to.node, port: 'in' }, ...(send && { send }), }; } /** 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[]; renamed: RenamedNode[] }; edges: { added: GraphEdge[]; removed: Removed[] }; } 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, removedEdges: ReadonlySet, ): 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 { const problems: Record = {}; 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; }