Route sounds, songs and buses in one Master routing graph (#351)
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 09:18:46 +02:00
1 parent 8891f027db
commit 0e73fd85b9
46 files changed
+1253 -311

No files matched your search

+16 -5
View File
@@ -26,14 +26,16 @@ import {
soundFxParamsMismatch,
startStateOf,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing';
import { findAsset, findGraphAsset } from '../../core/model/Project';
import { ROUTED_ASSET } from '../../core/music/routingNodes';
import { hasAssetEditor } from '../assetTabs';
import { findAsset, findGraphAsset, type GraphHolder } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
import { BeadClock } from './beads';
@@ -281,7 +283,7 @@ export class GraphCanvas extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
private asset(): GraphAsset | null {
private asset(): GraphHolder | null {
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
}
@@ -316,7 +318,9 @@ export class GraphCanvas extends Widget {
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: asset.type === 'song'
? routingProblems(asset.graph)
: null;
: asset.type === 'routing'
? masterRoutingProblems(asset.graph)
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
@@ -429,7 +433,7 @@ export class GraphCanvas extends Widget {
* one held by the selected object's instance. Without a selection nothing is strong – that keeps
* "many instances, many states" readable instead of picking one of them at random.
*/
private stateMarks(asset: GraphAsset): Map<string, 'held' | 'current'> {
private stateMarks(asset: GraphHolder): Map<string, 'held' | 'current'> {
const marks = new Map<string, 'held' | 'current'>();
if (asset.type !== 'statemachine') return marks;
const selection = this.state.selection;
@@ -665,6 +669,13 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
if (node.type === ROUTED_ASSET) {
const asset = findAsset(this.state.project, node.id);
if (!asset || !hasAssetEditor(asset.type)) return false;
this.state.openAsset(node.id);
return true;
}
if (node.type === SHADER_CALL || node.type === INSTRUMENT_SOUND_FX) {
const shader = node.type === SHADER_CALL;
const id = textOf(node.params[shader ? FUNCTION_PARAM : 'fx'] ?? '');
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AddNodeCommand } from '../../core/commands/graphCommands';
import type { GraphAsset } from '../../core/model/Asset';
import type { GraphHolder } from '../../core/model/Project';
import type { Box } from '../framing';
import type { EditorState } from '../state';
import type { NodeView } from './NodeView';
@@ -11,7 +11,7 @@ export interface GraphCanvasHost {
readonly state: EditorState;
/** The asset on the canvas; empty when there is none. */
assetId(): string;
asset(): GraphAsset | null;
asset(): GraphHolder | null;
views(): ReadonlyMap<string, NodeView>;
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
topDownViews(): Iterable<NodeView>;
+5 -5
View File
@@ -21,11 +21,11 @@ import {
STATE_NAME_PARAM,
terminalNode,
} from '../../core/graph';
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import type { GraphHolder, Project } from '../../core/model/Project';
import {
isParticlePropertyNode,
PARTICLE_PROPERTY,
@@ -132,14 +132,14 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
export function paletteEntries(project: Project, asset: GraphHolder): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
// A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350).
if (asset.type === 'song') return [];
// A routing holds nodes that come and go on their own: a song's tracks (#350), the assets (#351).
if (asset.type === 'song' || asset.type === 'routing') return [];
if (asset.type === 'soundfx') {
const placed = new Set(asset.graph.nodes.map((n) => n.type));
return SOUND_FX_NODES.filter(