Files
feuerwerk/tests/core/master-routing.test.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
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>
2026-10-09 09:18:46 +02:00

266 lines
11 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,
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('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([]);
});
});