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