Route sounds, songs and buses in one Master routing graph (#351)
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:
1 parent
8891f027db
commit
0e73fd85b9
46 files changed
+1253
-311
No files matched your search
+100
-31
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user