Route the tracks of a song through a graph (#350)
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s

Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 20:18:09 +02:00
1 parent 3a52f2290f
commit daeffe0f9d
30 files changed
+1323 -104

No files matched your search

+133 -12
View File
@@ -443,6 +443,8 @@ export async function song(t) {
60,
);
const busOf = (track) => `song:${id}:${track}`;
/** Where a track's own items go in, on to its bus (#350). */
const itemsOf = (track) => `${busOf(track)}:#items`;
const firstClip = played.clips[0];
const notes = played.planned.slice(0, 2);
check(
@@ -450,10 +452,10 @@ export async function song(t) {
played.playing &&
Boolean(firstClip) &&
firstClip.sound === SOUND &&
firstClip.bus === busOf(tracks[0]) &&
firstClip.bus === itemsOf(tracks[0]) &&
Math.abs(firstClip.duration - 2) < 1e-9 &&
notes.length === 2 &&
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(tracks[2]) && n.tempo === 120) &&
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === itemsOf(tracks[2]) && n.tempo === 120) &&
Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 &&
Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 &&
notes[0].note === 60,
@@ -469,13 +471,15 @@ export async function song(t) {
JSON.stringify(buses),
);
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
const muted = await waitFor(
() => json('__gm.song.buses()'),
(list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0,
);
// Mute closes every send of the track (#350); its own level stays as it is set.
const closed = (list) => {
const sends = list.find((b) => b.name === busOf(tracks[0]))?.sends ?? [];
return sends.length > 0 && sends.every((send) => send.gain === 0);
};
const muted = await waitFor(() => json('__gm.song.buses()'), closed);
check(
'muting a track while it plays silences its bus at once',
muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')),
'muting a track while it plays closes its sends at once',
closed(muted) && (await json('__gm.song.playing()')),
JSON.stringify(muted),
);
await shot('02-playing');
@@ -603,8 +607,8 @@ export async function song(t) {
run.owner !== '' &&
run.clips.length === 1 &&
gameClip?.sound === SOUND &&
gameClip.bus === busOf(tracks[0]) &&
gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) &&
gameClip.bus === itemsOf(tracks[0]) &&
gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) &&
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6,
@@ -837,8 +841,8 @@ export async function song(t) {
'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time',
fxBus?.fx === true &&
Math.abs(fxBus.latency - frame) < 1e-9 &&
fxNote?.bus === busOf(tracks[1]) &&
plainClip?.bus === busOf(tracks[0]) &&
fxNote?.bus === itemsOf(tracks[1]) &&
plainClip?.bus === itemsOf(tracks[0]) &&
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
Math.abs(clipStart - plainClip.at) < 1e-6,
@@ -855,6 +859,123 @@ export async function song(t) {
JSON.stringify(released),
);
// 65l. Routing (#350): the switch in the header shows the song as a graph, a node per track and
// the output, every track linked to it. A drag from one track's out into another's in sends
// it there, one back would close a loop and is refused, Delete takes a link but not a track.
// A track linked nowhere says it is not heard. Volume and Mode of a link in the inspector
// are what its send is set to when the song plays.
await evaluate('__gm.sound.stopAll()');
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
await sleep(200);
p = await json("__gm.song.viewAt('routing')");
await click(p.x, p.y);
await sleep(300);
const routingOf = () => json(`__gm.song.routing.graph(${JSON.stringify(id)})`);
// The tracks as they are now: more than in 65a.
const ids = (await model()).tracks.map((track) => track.id);
const linksOf = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
const opened65 = await json(`(() => {
const graph = __gm.song.routing.graph(${JSON.stringify(id)});
return { shown: __gm.song.shown(), graph, labels: graph.nodes.map((n) => __gm.song.routing.label(n.id)) };
})()`);
const names = (await model()).tracks.map((track) => track.name);
await shot('05-routing');
check(
'Routing shows a node per track and the output, every track linked to the output',
opened65.shown === 'routing' &&
JSON.stringify(opened65.graph.nodes.map((n) => n.id)) === JSON.stringify([...ids, '#output']) &&
JSON.stringify(opened65.labels) === JSON.stringify([...names, 'Output']) &&
JSON.stringify(linksOf(opened65.graph)) === JSON.stringify(ids.map((track) => `${track}>#output`)),
JSON.stringify(opened65),
);
const port = (nodeId, key, isOutput) =>
json(`__gm.song.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
let fromPort = await port(ids[0], 'out', true);
let toPort = await port(ids[1], 'in', false);
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
await sleep(200);
const linked = await routingOf();
fromPort = await port(ids[1], 'out', true);
toPort = await port(ids[0], 'in', false);
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
await sleep(200);
const refused = await routingOf();
check(
'a drag from a track into another links them, one back would close a loop and is refused',
linksOf(linked).includes(`${ids[0]}>${ids[1]}`) &&
linked.edges.length === ids.length + 1 &&
JSON.stringify(refused.edges) === JSON.stringify(linked.edges),
JSON.stringify({ linked: linksOf(linked), refused: linksOf(refused) }),
);
const toOutput = (graph, track) =>
graph.edges.find((e) => e.from.node === track && e.to.node === '#output');
for (const track of [ids[0], ids[2]]) {
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(toOutput(await routingOf(), track).id)})`);
await click(p.x, p.y);
await press('Delete');
await sleep(150);
}
p = await json(`__gm.song.routing.nodeAt(${JSON.stringify(ids[3])})`);
await click(p.x, p.y);
await press('Delete');
await sleep(150);
const trimmed = await routingOf();
const nowhere = await json(`__gm.song.routing.problem(${JSON.stringify(ids[2])})`);
check(
'Delete takes a link but never a track, and a track linked nowhere says it is not heard',
!toOutput(trimmed, ids[0]) &&
!toOutput(trimmed, ids[2]) &&
trimmed.nodes.length === ids.length + 1 &&
String(nowhere).startsWith('Goes nowhere') &&
(await json(`__gm.song.routing.problem(${JSON.stringify(ids[0])})`)) === null,
JSON.stringify({ links: linksOf(trimmed), nodes: trimmed.nodes.length, nowhere }),
);
const send = trimmed.edges.find((e) => e.from.node === ids[0] && e.to.node === ids[1]);
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(send.id)})`);
await click(p.x, p.y);
await sleep(200);
p = await json("__gm.inspectorField('Volume')");
await click(p.x, p.y);
await t.typeText('-6');
await press('Enter');
await sleep(150);
p = await json("__gm.inspectorField('Mode')");
await click(p.x, p.y);
await sleep(150);
await clickText('Pre volume (Pre FX)');
await sleep(150);
const set = (await routingOf()).edges.find((e) => e.id === send.id);
await shot('06-send');
check(
'Volume and Mode of a link in the inspector set its send',
set?.send?.volume === -6 && set.send.tap === 'preFx',
JSON.stringify(set),
);
p = await json('__gm.song.playAt()');
await click(p.x, p.y);
const sending = await waitFor(
() => json('__gm.song.buses()'),
(list) => list.some((b) => b.name === busOf(ids[0]) && b.sends),
);
const sends = (track) => sending.find((b) => b.name === busOf(track))?.sends;
check(
'playing, each track goes where its links say: through the send set, nowhere for the one without',
sends(ids[0])?.length === 1 &&
sends(ids[0])[0].to === busOf(ids[1]) &&
sends(ids[0])[0].tap === 'preFx' &&
Math.abs(sends(ids[0])[0].gain - 10 ** (-6 / 20)) < 1e-6 &&
sends(ids[1])?.length === 1 &&
sends(ids[2])?.length === 0,
JSON.stringify(sending.filter((b) => b.name.startsWith(`song:${id}:`))),
);
await click(p.x, p.y);
p = await json("__gm.song.viewAt('timeline')");
await click(p.x, p.y);
await sleep(150);
// 65j. The step takes back everything it made.
await evaluate('__gm.sound.stopAll()');
await mutate(`