Files
feuerwerk/tests/core/master-routing.test.ts
T
adminandClaude Opus 5.5 f840ec3c44
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Failing after 14m40s
Snap a new song to 1/16, and route new assets as the others (#365)
A new song snapped to whole bars, too coarse to lay out a beat. And a
new or duplicated sound, instrument, pattern or song went to the
master alone, so every one had to be linked into the buses by hand
again.

- A song without a grid of its own snaps to 1/16. The songs in the
  user's project have Bar written in and keep it.
- A new or duplicated sound, instrument, pattern or song gets the links
  of the one whose targets most others of its type share, at their
  volume. On a tie the latest of them decides, so a single song decides
  for the next one.
- Where nothing of its type is linked yet, it goes as most of all that
  plays, and where nothing is, to the master. A new bus still goes to
  the master alone.
- Assets made at once are matched against what stood before, not
  against each other. A project from before the routing still opens
  with everything on the master.
- The song tab has a gridAt hook. e2e 65 checks the new grid and sets
  Bar, which its checks go by.

Tests:

- Unit master-routing (1 new) and song (1 rewritten): 26 green. Counter
  checks (rule off, grid back to Bar) red.
- e2e 65 (melektaus-atx): ALL PASSED, 58 checks (1 new).
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4226 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:37:44 +02:00

311 lines
13 KiB
TypeScript

/**
* The master routing (#351): a node per sound, instrument, pattern, song and bus and the master,
* following the assets through every edit and its undo, and the mix the links make.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
DeleteAssetCommand,
DuplicateAssetCommand,
RenameAssetCommand,
} from '../../src/core/commands/assetCommands';
import type { Command } from '../../src/core/commands/Command';
import { ConnectCommand, DisconnectCommand } from '../../src/core/commands/graphCommands';
import { KeepRoutingCommand } from '../../src/core/commands/routingCommands';
import { SetSendCommand } from '../../src/core/commands/songCommands';
import type { Graph } from '../../src/core/graph/Graph';
import { ROUTING_ID } from '../../src/core/model/Project';
import { MASTER_MIX, mixBusName, mixOf, playedRoute, routeOf, sameMix } from '../../src/core/music/mixer';
import { heldRouting, masterRoutingProblems, settleRouting } from '../../src/core/music/projectRouting';
import { ROUTED_ASSET, ROUTED_MASTER } from '../../src/core/music/routingNodes';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'b2'.repeat(32)}.wav`;
/** A project edited as the editor edits it: every command keeps the routing in step (#351). */
function edited() {
const f = makeProject();
const run = (command: Command) => f.stack.execute(new KeepRoutingCommand(command));
const create = (type: 'bus' | 'instrument' | 'pattern' | 'song' | 'scene', name: string) => {
const command = new CreateAssetCommand(type, name);
run(command);
return command.asset.id;
};
const sound = (name: string) => {
const command = new CreateSoundCommand(name, FILE);
run(command);
return command.asset.id;
};
const link = (from: string, to: string) => {
const command = new ConnectCommand(ROUTING_ID, { node: from, port: 'out' }, { node: to, port: 'in' });
run(command);
return command.edge.id;
};
const linksOf = (id: string) =>
f.project.routing.edges.filter((e) => e.from.node === id).map((e) => e.to.node);
return { ...f, run, create, sound, link, linksOf };
}
const edge = (from: string, to: string, extra: object = {}) => ({
id: `${from}>${to}`,
from: { node: from, port: 'out' },
to: { node: to, port: 'in' },
...extra,
});
describe('the nodes of the master routing (#351)', () => {
it('come with each sound, instrument, pattern, song and bus, linked to the master', () => {
const f = edited();
const ids = [f.sound('Kick'), f.create('instrument', 'Lead'), f.create('pattern', 'Riff')];
ids.push(f.create('song', 'Theme'), f.create('bus', 'Reverb'), f.sound('Snare'));
f.create('scene', 'Level');
const nodes = f.project.routing.nodes;
expect(nodes.map((n) => n.id).sort()).toEqual([...ids, MASTER_BUS].sort());
expect(nodes.find((n) => n.id === MASTER_BUS)?.type).toBe(ROUTED_MASTER);
for (const id of ids) expect(f.linksOf(id)).toEqual([MASTER_BUS]);
const at = (id: string) => nodes.find((n) => n.id === id)!;
expect(at(ids[0]!).type).toBe(ROUTED_ASSET);
expect(at(ids[0]!).params).toEqual({ name: 'Kick', kind: 'sound' });
// What plays below each other in the first column, the buses in the second, the master right of
// them a row above all of it: no link into it runs behind a bus.
expect(ids.map((id) => [at(id).x, at(id).y])).toEqual([
[0, 0],
[0, 80],
[0, 160],
[0, 240],
[320, 0],
[0, 320],
]);
expect([at(MASTER_BUS).x, at(MASTER_BUS).y]).toEqual([640, -80]);
});
it('link a new or duplicated asset where most others of its type go, else where all that plays goes', () => {
const f = edited();
const reverb = f.create('bus', 'Reverb');
const drums = f.create('bus', 'Drums');
const kick = f.sound('Kick');
const snare = f.sound('Snare');
const hat = f.sound('Hat');
const offMaster = (id: string) =>
f.run(new DisconnectCommand(ROUTING_ID, f.project.routing.edges.find((e) => e.from.node === id)!.id));
// Two sounds go into Drums and Reverb, one into the master alone: a new one goes with the two.
for (const id of [kick, snare]) {
offMaster(id);
f.link(id, drums);
f.run(new SetSendCommand(ROUTING_ID, f.link(id, reverb), { volume: -9, tap: 'post' }));
}
const tom = f.sound('Tom');
expect(f.linksOf(tom).sort()).toEqual([drums, reverb].sort());
const sendTo = (id: string, to: string) =>
f.project.routing.edges.find((e) => e.from.node === id && e.to.node === to)?.send;
expect(sendTo(tom, reverb)).toEqual({ volume: -9, tap: 'post' });
const copy = new DuplicateAssetCommand(hat);
f.run(copy);
expect(f.linksOf(copy.copyId).sort()).toEqual([drums, reverb].sort());
expect(f.linksOf(hat)).toEqual([MASTER_BUS]);
// The first song goes as most that plays; the one song there is then decides for the next.
const theme = f.create('song', 'Theme');
expect(f.linksOf(theme).sort()).toEqual([drums, reverb].sort());
f.run(
new DisconnectCommand(
ROUTING_ID,
f.project.routing.edges.find((e) => e.from.node === theme && e.to.node === drums)!.id,
),
);
expect(f.linksOf(f.create('song', 'Outro'))).toEqual([reverb]);
expect(f.linksOf(f.create('pattern', 'Riff')).sort()).toEqual([drums, reverb].sort());
// A bus goes to the master as before.
expect(f.linksOf(f.create('bus', 'Delay'))).toEqual([MASTER_BUS]);
// Undone, a new asset takes its links along.
const links = f.project.routing.edges.length;
f.stack.undo();
expect(f.project.routing.edges.length).toBe(links - 1);
});
it('come in by type as a project from before the routing is opened', () => {
const f = makeProject();
for (const command of [
new CreateAssetCommand('bus', 'Reverb'),
new CreateAssetCommand('song', 'Theme'),
new CreateSoundCommand('Kick', FILE),
new CreateAssetCommand('instrument', 'Lead'),
])
f.stack.execute(command);
settleRouting(f.project);
const shown = f.project.routing.nodes.map((n) => [n.params.name ?? 'Master', n.x, n.y]);
expect(shown).toEqual([
['Kick', 0, 0],
['Lead', 0, 80],
['Theme', 0, 160],
['Reverb', 320, 0],
['Master', 640, -80],
]);
});
it('follow a rename, and go with their asset and its links, all of it back on undo', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const onMaster = f.project.routing.edges.find((e) => e.from.node === kick)!;
f.link(kick, reverb);
f.run(new DisconnectCommand(ROUTING_ID, onMaster.id));
expect(f.linksOf(kick)).toEqual([reverb]);
f.run(new RenameAssetCommand(kick, 'Big kick'));
expect(f.project.routing.nodes.find((n) => n.id === kick)?.params.name).toBe('Big kick');
f.stack.undo();
expect(f.project.routing.nodes.find((n) => n.id === kick)?.params.name).toBe('Kick');
const before = JSON.stringify(f.project.routing);
f.run(new DeleteAssetCommand(reverb));
expect(f.project.routing.nodes.some((n) => n.id === reverb)).toBe(false);
// The kick went only into the bus, and goes to the master now: deleting a bus silences nothing.
expect(f.linksOf(kick)).toEqual([MASTER_BUS]);
f.stack.undo();
expect(JSON.stringify(f.project.routing)).toBe(before);
f.stack.redo();
expect(f.project.routing.nodes.some((n) => n.id === reverb)).toBe(false);
expect(f.linksOf(kick)).toEqual([MASTER_BUS]);
});
it('leave a node without a link alone when a bus goes that it was not sent to', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const send = f.project.routing.edges.find((e) => e.from.node === kick)!;
f.stack.execute(
new KeepRoutingCommand({
label: 'Unlink',
execute: (p) => void p.routing.edges.splice(p.routing.edges.indexOf(send), 1),
undo: () => {},
}),
);
f.run(new DeleteAssetCommand(reverb));
expect(f.linksOf(kick)).toEqual([]);
expect(masterRoutingProblems(f.project.routing)[kick]).toMatch(/^Goes nowhere/);
});
it('are not added as a project is read, and a settled routing reads back as it was', () => {
const f = makeProject();
f.stack.execute(new CreateSoundCommand('Kick', FILE));
expect(roundTrip(f.project).routing).toEqual({ nodes: [], edges: [] });
const g = edited();
g.link(g.sound('Kick'), g.create('bus', 'Reverb'));
g.create('pattern', 'Riff');
expect(roundTrip(g.project)).toEqual(g.project);
});
it('keep no link that would loop, say again what another says, or go into what is no bus', () => {
const assets = [
{ id: 'a', type: 'bus', name: 'A', props: {} },
{ id: 'b', type: 'bus', name: 'B', props: {} },
{ id: 's', type: 'sound', name: 'S', props: {} },
{ id: 'p', type: 'pattern', name: 'P', props: {} },
] as never[];
const node = (id: string, kind: string) => ({
id,
type: ROUTED_ASSET,
x: 0,
y: 0,
params: { name: id, kind },
});
const graph: Graph = {
nodes: [node('a', 'bus'), node('b', 'bus'), node('s', 'sound'), node('p', 'pattern')],
edges: [
edge('a', 'b'),
edge('b', 'a'),
edge('s', 'a', { send: { volume: -6, tap: 'preFx' } }),
{ ...edge('s', 'a'), id: 'again' },
edge('p', 's'),
edge('b', MASTER_BUS, { send: { volume: 3, tap: 'preFx' } }),
],
};
const held = heldRouting(graph, assets, false);
expect(held.edges.map((e) => e.id)).toEqual(['a>b', 's>a', 'b>#master', expect.any(String)]);
// A sound sends after its volume and FX; a bus may tap before them.
expect(held.edges.find((e) => e.id === 's>a')?.send).toEqual({ volume: -6, tap: 'post' });
expect(held.edges.find((e) => e.id === 'b>#master')?.send).toEqual({ volume: 3, tap: 'preFx' });
// The pattern's one link went into a sound: it goes to the master instead.
expect(held.edges.at(-1)).toMatchObject({ from: { node: 'p' }, to: { node: MASTER_BUS } });
});
it('take a send of a sound only after its volume, and of a bus from where it is asked', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const toBus = f.link(kick, reverb);
const out = f.project.routing.edges.find((e) => e.from.node === reverb)!.id;
f.run(new SetSendCommand(ROUTING_ID, toBus, { volume: -12, tap: 'preFx' }));
f.run(new SetSendCommand(ROUTING_ID, out, { volume: 0, tap: 'postFx' }));
const sendOf = (id: string) => f.project.routing.edges.find((e) => e.id === id)?.send;
expect([sendOf(toBus), sendOf(out)]).toEqual([
{ volume: -12, tap: 'post' },
{ volume: 0, tap: 'postFx' },
]);
});
});
describe('the mix of the master routing (#351)', () => {
it('sends what has no node, or one link at 0 dB, straight into that bus', () => {
const f = makeProject();
const sound = new CreateSoundCommand('Kick', FILE);
f.stack.execute(sound);
expect(routeOf(f.project, sound.asset.id)).toBe(MASTER_MIX);
const g = edited();
const kick = g.sound('Kick');
const reverb = g.create('bus', 'Reverb');
expect(routeOf(g.project, kick)).toBe(MASTER_MIX);
g.link(kick, reverb);
g.run({
label: 'Off the master',
execute: (p) =>
void (p.routing.edges = p.routing.edges.filter(
(e) => !(e.from.node === kick && e.to.node === MASTER_BUS),
)),
undo: () => {},
});
expect(routeOf(g.project, kick)).toBe(mixBusName(reverb));
});
it('gives a sound with more than that a bus of its own with its sends, and plays a song along them', () => {
const f = edited();
const kick = f.sound('Kick');
const song = f.create('song', 'Theme');
const reverb = f.create('bus', 'Reverb');
const send = f.link(kick, reverb);
f.run(new SetSendCommand(ROUTING_ID, send, { volume: -6, tap: 'post' }));
const before = mixOf(f.project);
const own = before.find((bus) => bus.name === mixBusName(kick));
expect(own).toMatchObject({ volume: 1, fx: null });
expect(own?.sends?.map((s) => [s.to, s.tap, Math.round(s.gain * 1000) / 1000])).toEqual([
[MASTER_MIX, 'post', 1],
[mixBusName(reverb), 'post', 0.501],
]);
expect(before.find((bus) => bus.name === mixBusName(reverb))).toMatchObject({ into: MASTER_MIX });
expect(before.some((bus) => bus.name === mixBusName(song))).toBe(false);
f.run(new SetSendCommand(ROUTING_ID, send, { volume: -3, tap: 'post' }));
expect(sameMix(before, mixOf(f.project))).toBe(false);
const toReverb = f.link(song, reverb);
f.run(new SetSendCommand(ROUTING_ID, toReverb, { volume: 0, tap: 'preFx' }));
const route = playedRoute(f.project, '', song);
expect(typeof route !== 'string' && route.map((s) => [s.to, s.tap])).toEqual([
[MASTER_MIX, 'post'],
[mixBusName(reverb), 'preFx'],
]);
// A bus the node picks takes it there alone.
expect(playedRoute(f.project, reverb, song)).toBe(mixBusName(reverb));
expect(playedRoute(f.project, MASTER_BUS, song)).toBe(MASTER_MIX);
});
it('plays an asset with no link at all on nothing', () => {
const f = edited();
const kick = f.sound('Kick');
f.run({ label: 'Unlink', execute: (p) => void (p.routing.edges = []), undo: () => {} });
expect(routeOf(f.project, kick)).toEqual([]);
expect(mixOf(f.project).find((bus) => bus.name === mixBusName(kick))?.sends).toEqual([]);
});
});