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

+100 -31
View File
@@ -20,7 +20,7 @@ const DRONE_BYTES = 12044;
export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
const { select } = t;
const { select, drag } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
@@ -2262,55 +2262,123 @@ export async function sound(t) {
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
);
// 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the
// master's volume is a project setting, one undo step.
// 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
// one; the tab has a node for it and for the sound, each linked to the master. Links drawn
// there and one deleted send the sound into a bus and that bus into the other, as the Sound
// tab then plays it. A click on a node shows its asset, on the master the master's volume.
for (let i = 0; i < 2; i++) {
await clickText('+');
await pickAssetType(t, 'Bus');
await sleep(150);
}
const [busA, busB] = await assetsOfType('bus');
const intoMenu = async () => {
await select({ kind: 'asset', assetId: busA.id });
const at = await json("__gm.inspectorField('Into', true)");
await click(at.x, at.y);
const offered = await json('__gm.overlayTexts()');
await press('Escape');
return offered;
};
const offeredBefore = await intoMenu();
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`);
const offeredAfter = await intoMenu();
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(300);
const routing = () => json('__gm.routing.graph()');
const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() };
const labels = await json(
`[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`,
);
await select({ kind: 'asset', assetId: droneId });
const busRow = await json("__gm.inspectorField('Bus', true)");
await select({ kind: 'asset', assetId: busA.id });
const intoRow = await json("__gm.inspectorField('Into', true)");
check(
'"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it',
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') &&
JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) &&
JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']),
JSON.stringify({ busA, busB, offeredBefore, offeredAfter }),
'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows',
opened.shown &&
[droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) &&
[droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) &&
JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) &&
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) &&
busRow === null &&
intoRow === null,
JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }),
);
const portOf = (nodeId, key, isOutput) =>
json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const link = async (from, to) => {
const a = await portOf(from, 'out', true);
const b = await portOf(to, 'in', false);
await drag(a.x, a.y, b.x, b.y);
await sleep(200);
};
// The link is picked by the model: its line may run behind a node, as lines do.
const unlink = async (from, to) => {
const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to);
await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] });
await press('Delete');
await sleep(150);
};
await link(droneId, busB.id);
await link(busB.id, busA.id);
await unlink(droneId, '#master');
await unlink(busB.id, '#master');
await shot('h3-routing');
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ${JSON.stringify(busB.id)};`);
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10);
await sleep(300);
const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus;
const peak = await waitFor(
() => json(`__gm.routing.peaks([${JSON.stringify(`mix:${busB.id}`)}])`),
(peaks) => Math.max(...(peaks[`mix:${busB.id}`] ?? [0])) > 0.01,
);
await click(tabPlay.x, tabPlay.y);
const levelOf = (name) => buses.find((bus) => bus.name === name);
check(
'the Sound tab plays the sound on its bus, into the bus that one names, on into the master',
heardOn === `mix:${busB.id}` &&
'linked in the routing, the Sound tab plays the sound on its bus, into the bus that one goes to, on into the master',
links(await routing())
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.id))
.join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` &&
heardOn === `mix:${busB.id}` &&
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null,
JSON.stringify({ heardOn, buses }),
levelOf('mix')?.into === null &&
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) > 0.01,
JSON.stringify({ links: links(await routing()), heardOn, buses, peak }),
);
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(200);
let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`);
await click(at.x, at.y);
await sleep(200);
const busShown = await json("__gm.inspectorField('Volume', true)");
at = await json("__gm.routing.nodeAt('#master')");
await click(at.x, at.y);
await sleep(200);
await shot('h3-master');
const masterField = await json("__gm.inspectorField('Volume', true)");
await click(masterField.x, masterField.y);
await press('a', { ctrl: true });
await t.typeText('0.6');
await press('Enter');
await sleep(150);
const masterSet = await json('__gm.state.project.masterVolume');
await t.clickToolbar('undo');
await sleep(150);
const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') };
at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`);
await click(at.x, at.y);
await click(at.x, at.y);
await sleep(300);
check(
'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound',
busShown !== null &&
masterShown.set === 0.6 &&
masterShown.undone === 1 &&
(await json('__gm.soundTab.current()')) === droneId &&
(await json('__gm.routing.shown()')) === false,
JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }),
);
await t.clickToolbar('menu');
@@ -2337,10 +2405,11 @@ export async function sound(t) {
mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
JSON.stringify({ masterAt, mastered, undone }),
);
// The routing starts again from the assets with the next edit: everything on the master.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = '';
p.assets = p.assets.filter((a) => a.type !== 'bus');`);
p.assets = p.assets.filter((a) => a.type !== 'bus');
p.routing = { nodes: [], edges: [] };`);
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`