Files
feuerwerk/src/core/music/projectRouting.ts
T
adminandClaude Opus 5.5 0701fe3856
feuerwerk / gate (push) Successful in 5m5s
feuerwerk / suite (push) Failing after 18m39s
Share the parts the sound devices and timelines repeated (#85)
Round 12 of the clean-up. Duplicate code had grown from 33 clones
(249 lines) after round 11 to 140 clones (1263 lines), almost all of
it in the panels and faces of the sound FX devices (#379 to #401).
It is down to 57 clones (416 lines) now, and src is 816 lines
shorter.

- deviceParts.ts holds what the device panels built by hand: the
  head, rows of knobs, the screen style and the row layout for
  groups of knobs. Twelve panels use it. panel.knobs stays because
  the debug hooks read it.
- DeviceFace is the base of the twelve faces. It finds the node and
  does wired, preview, commit, set, hide, reload and audition.
  compressorWired lives there as paramWired.
- TextPool replaces ten copies of "make texts as needed, hide the
  rest" in the wave and video tabs, the rulers, the piano roll and
  the song and composition timelines. clipStrip.ts is the clip label
  strip the wave and video tabs share.
- core/util/bytes.ts has the concat that ogg, matroska and avcColour
  each wrote out.
- Dead code is gone: clear on the video players (never called since
  #402), posterFileOf and videoTimeAt (only tests called them) and
  easeOut (the same as glide(t, 3)). Values only used in their own
  file lose their export.
- Comments over 125 columns are wrapped, and comments that told the
  history ("as before", "used to") say what is, in src and in the
  e2e modules.
- Left alone on purpose: AnimationEditor and CompositionEditor share
  only the play button, and the curve panels are built differently.
- Chip now places its boxes through put, which rounds. They were
  whole numbers already. The three row layouts are byte-identical
  before and after.
- README lists the video and composition assets and the Videos and
  Compositions pages.

Tests:
- Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests.
- e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks.
- TextPool without keeping its texts and framesToRoom without making
  room both go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:05 +02:00

303 lines
13 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,
* 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<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 lost = new Set<string>();
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<string, GraphEdge[]>();
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<string, number>();
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<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;
}