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>
216 lines
9.4 KiB
TypeScript
216 lines
9.4 KiB
TypeScript
import {
|
||
callProblem,
|
||
declaresPropertyPorts,
|
||
declaresSoundFxPorts,
|
||
declaresVariablePorts,
|
||
edgesInto,
|
||
edgesOfNode,
|
||
findNode,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
inlineInputDef,
|
||
inlineInputs,
|
||
isStateNode,
|
||
MACHINE_PORT,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_TYPE_PARAM,
|
||
portSignature,
|
||
resolveDef,
|
||
soundFxParamsMismatch,
|
||
soundFxSourceOf,
|
||
STATE_TRANSITION,
|
||
stateName,
|
||
STRUCT_TYPE_PARAM,
|
||
STRUCT_TYPE_SETTING,
|
||
TRANSITION_PARAM,
|
||
variablesSourceOf,
|
||
} from '../../core/graph';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project';
|
||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||
import {
|
||
isListNode,
|
||
isTypedNode,
|
||
listNodeItemType,
|
||
nodeStructType,
|
||
structAssetOf,
|
||
} from '../../core/model/structTypes';
|
||
import { SetListNodeTypeCommand, SetStructTypeCommand } from '../../core/commands/structTypeCommands';
|
||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||
import { Label } from '../../ui';
|
||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||
import { addNote, addTitle, type InspectorHost } from './host';
|
||
import {
|
||
buildPortSection,
|
||
buildPropertyPortSection,
|
||
buildSoundFxPortSection,
|
||
buildVariablePortSection,
|
||
variableChoices,
|
||
} from './portSection';
|
||
import { listSource, previewingHandlers } from './previewHandlers';
|
||
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
|
||
import { currentNode, nodeParamValue } from './valueSpecs';
|
||
|
||
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
||
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
||
const asset = findGraphAsset(project, assetId);
|
||
if (!asset) return 'missing';
|
||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||
if (!node) return 'missing';
|
||
const lookup = graphLookup(project);
|
||
const def = resolveDef(node, asset.graph, lookup);
|
||
const connected = def
|
||
? inlineInputs(def)
|
||
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
||
.map((p) => p.key)
|
||
: [];
|
||
// The machine a port carries is drawn as the picker's text, its name included.
|
||
const machineOf = (id: string) => findAssetOf(project, id, 'statemachine')?.name ?? '';
|
||
const declared = (node.ports ?? [])
|
||
.map(
|
||
(p) =>
|
||
`${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}${
|
||
p.default !== undefined ? `=${p.default}` : ''
|
||
}`,
|
||
)
|
||
.join(',');
|
||
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
|
||
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
|
||
// The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so
|
||
// every part of them a command can change has to force a rebuild.
|
||
const transitions = isStateNode(node)
|
||
? edgesOfNode(asset.graph, node.id)
|
||
.map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`)
|
||
.join(',')
|
||
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
|
||
// The source names which declarations the port menu offers, so switching it has to rebuild.
|
||
const typeId = nodeStructType(node) || listNodeItemType(node);
|
||
const typeName = structAssetOf(project, typeId)?.name ?? '';
|
||
const source = `${variablesSourceOf(node.params)}:${soundFxSourceOf(node.params)}:${typeId}:${typeName}`;
|
||
const choices = variableChoices(project, assetId, node)
|
||
.map((v) => v.key)
|
||
.join(',');
|
||
// A setting that does nothing has no row, so picking an easing that needs it has to rebuild.
|
||
const idle = def?.idleParams?.(node.params, (key) => connected.includes(key)).join(',') ?? '';
|
||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(
|
||
',',
|
||
)}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}|${idle}`;
|
||
}
|
||
|
||
/** The name of the state at the other end of an edge – what a transition row is labelled with. */
|
||
function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string {
|
||
const far = findNode(graph, edge.from.node === nodeId ? edge.to.node : edge.from.node);
|
||
return far && isStateNode(far) ? stateName(far) : '';
|
||
}
|
||
|
||
/** A state machine declares no variables; its one node type has none to name. */
|
||
const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||
|
||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
||
const asset = findGraphAsset(host.state.project, assetId);
|
||
if (!asset) return;
|
||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||
const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project));
|
||
if (!node || !def) return;
|
||
|
||
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
||
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
||
addNote(host, def.description);
|
||
const problem =
|
||
callProblem(node, graphLookup(host.state.project), assetId) ?? soundFxParamsMismatch(node, asset.graph);
|
||
if (problem) addNote(host, problem, 'warning');
|
||
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
|
||
const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
|
||
host.fields.set(`param:${param.key}`, row);
|
||
host.body.add(row.row);
|
||
};
|
||
// Both draw their settings inside the port section: a setting decides what the menu offers.
|
||
const ownSettings =
|
||
declaresPropertyPorts(node.type) || declaresVariablePorts(node.type) || declaresSoundFxPorts(node.type);
|
||
// The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one
|
||
// rewrites the ports, which a plain param edit would not.
|
||
const typed = isTypedNode(node);
|
||
if (typed) {
|
||
const pick = (value: PropertyValue) => {
|
||
const target = { kind: 'node', assetId, nodeId } as const;
|
||
host.state.execute(new SetStructTypeCommand(target, textOf(value)));
|
||
};
|
||
addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick });
|
||
}
|
||
// A List node's type reshapes its items (#271), which is no plain param edit either.
|
||
const pickItemType = (value: PropertyValue) =>
|
||
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
|
||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
|
||
else if (declaresSoundFxPorts(node.type)) buildSoundFxPortSection(host, assetId, node, addSetting);
|
||
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
||
else if (def.declaresPorts) {
|
||
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
|
||
buildPortSection(host, assetId, node, variablesOf(asset), typeName);
|
||
}
|
||
|
||
const fed = (key: string) => edgesInto(asset.graph, node.id, key).length > 0;
|
||
const idle = new Set(def.idleParams?.(node.params, fed) ?? []);
|
||
const settings = def.params.filter(
|
||
(param) => !(typed && param.key === STRUCT_TYPE_PARAM) && !idle.has(param.key),
|
||
);
|
||
if (!ownSettings && settings.length > 0) {
|
||
addTitle(host, 'SETTINGS');
|
||
for (const param of settings) {
|
||
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
|
||
buildTransitionPicker(host, assetId, node, asset.graph);
|
||
else if (param.key === STRUCT_TYPE_PARAM && isListNode(node))
|
||
addSetting(param, { onInput: () => {}, onChange: pickItemType });
|
||
else addSetting(param);
|
||
}
|
||
}
|
||
if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph);
|
||
|
||
const free = inlineInputs(def).filter((p) => !fed(p.key));
|
||
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
|
||
for (const port of free) {
|
||
const inline = inlineInputDef(port);
|
||
const row = createFieldRow(
|
||
host,
|
||
inline,
|
||
node.params[port.key] ?? inline.default,
|
||
paramHandlers(host, assetId, nodeId, inline),
|
||
);
|
||
host.fields.set(`input:${port.key}`, row);
|
||
row.row.setEnabled(!idle.has(port.key));
|
||
host.body.add(row.row);
|
||
}
|
||
}
|
||
|
||
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
||
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
||
const found = currentNode(host.state.project, assetId, nodeId);
|
||
const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project));
|
||
if (!found || !def) return false;
|
||
for (const [key, row] of host.fields) {
|
||
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||
const port = def.inputs.find((p) => p.key === name);
|
||
const fallback =
|
||
scope === 'param'
|
||
? def.params.find((p) => p.key === name)?.default
|
||
: port && inlineInputDef(port).default;
|
||
row.setValue(found.node.params[name] ?? fallback ?? '');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Previews straight into the node's params; the command records the value from before the preview. */
|
||
function paramHandlers(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
nodeId: string,
|
||
def: PropertyDef,
|
||
): FieldHandlers {
|
||
const key = def.key;
|
||
return {
|
||
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
|
||
...listSource(host, def, () => ({ kind: 'node', assetId, nodeId, key })),
|
||
};
|
||
}
|