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>
311 lines
13 KiB
TypeScript
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([]);
|
|
});
|
|
});
|