Files
feuerwerk/tests/core/song-routing.test.ts
T
adminandClaude Opus 5.5 dc666016db Let solo win over the mute of the same track (#358)
A track with both M and S on stayed silent: the mute closed every send
before solo was asked. Pressing S on a muted track is meant to hear it.

- trackGates in routing.ts opens the sends of a soloed track even while
  it is muted. Its own items are heard as with solo alone.
- A muted track that a soloed one goes through, or that goes into it,
  stays silent as before.

Tests:
- Unit song-routing and song: 28 green (1 new), a counter-check red

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:22:59 +02:00

306 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands';
import { SetSendCommand, SetSongCommand } from '../../src/core/commands/songCommands';
import { canConnect, type Graph } from '../../src/core/graph';
import type { SongAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import {
arrivalOf,
heldSongGraph,
routingProblems,
SONG_OUTPUT_ID,
sendDelays,
songRoutes,
type TimedBus,
} from '../../src/core/music/routing';
import { SONG_OUTPUT, SONG_TRACK } from '../../src/core/music/routingNodes';
import { newTrack, type SongTrack, trackBus, trackItemsBus } from '../../src/core/music/song';
import { songBuses } from '../../src/core/music/songBuses';
import { makeProject, roundTrip } from '../helpers';
/** A song with tracks A, B, C and D, every one into the output, as a new song lays them out. */
function makeSong() {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
f.stack.execute(create);
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
const tracks = ['A', 'B', 'C', 'D'].map((name, i) => ({
...newTrack(i + 1),
id: name.toLowerCase(),
name,
}));
f.stack.execute(new SetSongCommand(song().id, { tracks }));
/** Takes every link away and lays down these instead, `''` the output. */
const route = (...links: [string, string][]) => {
song().graph.edges = [];
for (const [from, to] of links)
f.stack.execute(
new ConnectCommand(
song().id,
{ node: from, port: 'out' },
{ node: to || SONG_OUTPUT_ID, port: 'in' },
),
);
};
return { ...f, song, route };
}
const links = (graph: Graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
describe('the routing of a song (#350)', () => {
it('has a node for every track and the output, each new track linked to the output', () => {
const f = makeSong();
const graph = f.song().graph;
expect(graph.nodes.map((n) => [n.id, n.type, n.params.name ?? ''])).toEqual([
['a', SONG_TRACK, 'A'],
['b', SONG_TRACK, 'B'],
['c', SONG_TRACK, 'C'],
['d', SONG_TRACK, 'D'],
[SONG_OUTPUT_ID, SONG_OUTPUT, ''],
]);
expect(links(graph)).toEqual(['a>#output', 'b>#output', 'c>#output', 'd>#output']);
// Below one another, the output to the right of them.
expect(new Set(graph.nodes.slice(0, 4).map((n) => n.x)).size).toBe(1);
expect(graph.nodes.slice(0, 4).map((n) => n.y)).toEqual(
[...graph.nodes.slice(0, 4).map((n) => n.y)].sort((a, b) => a - b),
);
expect(graph.nodes[4]!.x).toBeGreaterThan(graph.nodes[0]!.x);
});
it('follows the tracks: a renamed one is renamed, a gone one takes its links along, an undo brings them back', () => {
const f = makeSong();
f.route(['a', 'b'], ['b', ''], ['c', 'b'], ['d', '']);
const before = JSON.stringify(f.song().graph);
const [a, b, c, d] = f.song().tracks as [SongTrack, SongTrack, SongTrack, SongTrack];
f.stack.execute(new SetSongCommand(f.song().id, { tracks: [a, { ...c, name: 'Drums' }, d] }));
expect(links(f.song().graph)).toEqual(['d>#output']);
expect(f.song().graph.nodes.find((n) => n.id === 'c')?.params.name).toBe('Drums');
f.stack.undo();
expect(JSON.stringify(f.song().graph)).toBe(before);
f.stack.execute(new SetSongCommand(f.song().id, { tracks: [a, b, c, d, { ...newTrack(5), id: 'e' }] }));
expect(links(f.song().graph)).toEqual(['a>b', 'b>#output', 'c>b', 'd>#output', 'e>#output']);
});
it('gives an old song without a routing every track into the output, and keeps what is right on load', () => {
const tracks = [
{ ...newTrack(1), id: 'a' },
{ ...newTrack(2), id: 'b' },
];
expect(links(heldSongGraph(undefined, tracks))).toEqual(['a>#output', 'b>#output']);
const edge = (id: string, from: string, to: string, send?: unknown) =>
({
id,
from: { node: from, port: 'out' },
to: { node: to, port: 'in' },
send,
}) as Graph['edges'][number];
const held = heldSongGraph(
{
nodes: [
{ id: 'a', type: SONG_TRACK, x: 0, y: 0, params: {} },
{ id: 'b', type: SONG_TRACK, x: 0, y: 96, params: {} },
{ id: 'gone', type: SONG_TRACK, x: 0, y: 192, params: {} },
],
edges: [
edge('1', 'a', 'b', { volume: 99, tap: 'preFx' }),
edge('2', 'b', 'a'),
edge('3', 'a', 'b'),
edge('4', 'b', 'gone'),
edge('5', 'b', SONG_OUTPUT_ID, { volume: 0, tap: 'post' }),
edge('6', 'a', 'a'),
],
},
tracks,
);
// The way back from B to A would close a loop, the second A to B says what the first does.
expect(held.edges.map((e) => [e.id, e.send ?? null])).toEqual([
['1', { volume: 12, tap: 'preFx' }],
['5', null],
]);
expect(held.nodes.map((n) => n.id)).toEqual(['a', 'b', SONG_OUTPUT_ID]);
});
it('survives the round trip with its sends, and a send is set and undone as one step', () => {
const f = makeSong();
const edge = f.song().graph.edges[0]!;
f.stack.execute(new SetSendCommand(f.song().id, edge.id, { volume: -6, tap: 'postFx' }));
expect(f.song().graph.edges[0]!.send).toEqual({ volume: -6, tap: 'postFx' });
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect(back.graph).toEqual(f.song().graph);
f.stack.undo();
expect(f.song().graph.edges[0]!.send).toBeUndefined();
});
it('never closes a loop, and neither adds nor deletes the node of a track', () => {
const f = makeSong();
f.route(['a', 'b'], ['b', 'c'], ['c', '']);
const id = f.song().id;
const lookup = graphLookup(f.project);
const side = (nodeId: string, isOutput: boolean) => ({ nodeId, port: isOutput ? 'out' : 'in', isOutput });
expect(canConnect(f.song().graph, side('c', true), side('a', false), lookup)).toBe(false);
expect(canConnect(f.song().graph, side('a', true), side('c', false), lookup)).toBe(true);
expect(() =>
f.stack.execute(new ConnectCommand(id, { node: 'c', port: 'out' }, { node: 'a', port: 'in' })),
).toThrow('loop');
expect(() => f.stack.execute(new RemoveNodeCommand(id, 'a'))).toThrow('on its own');
expect(() => f.stack.execute(new RemoveNodeCommand(id, SONG_OUTPUT_ID))).toThrow('on its own');
expect(() => f.stack.execute(new AddNodeCommand(id, SONG_TRACK, 0, 0))).toThrow('on its own');
expect(f.song().graph.nodes).toHaveLength(5);
});
it('says of a track that goes nowhere that it is not heard', () => {
const f = makeSong();
f.route(['a', ''], ['b', 'a']);
expect(Object.keys(routingProblems(f.song().graph))).toEqual(['c', 'd']);
});
});
describe('what the buses of a routed song are set to (#350)', () => {
it('sends a track into the buses its links go to, each at its level and from its tap', () => {
const f = makeSong();
f.route(['a', 'b'], ['a', ''], ['b', '']);
const ab = f.song().graph.edges[0]!;
f.stack.execute(new SetSendCommand(f.song().id, ab.id, { volume: -6, tap: 'preFx' }));
const buses = songBuses(f.project, f.song());
const id = f.song().id;
const sends = buses.find((bus) => bus.name === trackBus(id, 'a'))?.sends;
expect(sends?.map((send) => [send.to ?? '', +send.gain.toFixed(3), send.tap])).toEqual([
[trackBus(id, 'b'), 0.501, 'preFx'],
['', 1, 'post'],
]);
expect(buses.find((bus) => bus.name === trackBus(id, 'c'))?.sends).toEqual([]);
expect(buses.find((bus) => bus.name === trackItemsBus(id, 'a'))).toEqual({
name: trackItemsBus(id, 'a'),
gain: 1,
pan: 0,
into: trackBus(id, 'a'),
});
});
/** C into A into B into the output, D into the output: how loud each track's sends and items are. */
function heard(
change: (tracks: SongTrack[]) => SongTrack[],
routes: [string, string][] = [
['c', 'a'],
['a', 'b'],
['b', ''],
['d', ''],
],
) {
const f = makeSong();
f.route(...routes);
const song = { ...f.song(), tracks: change(f.song().tracks) };
const buses = songBuses(f.project, song);
const of = (name: string) => buses.find((bus) => bus.name === name)!;
return Object.fromEntries(
['a', 'b', 'c', 'd'].map((t) => [
t,
[
of(trackBus(song.id, t))
.sends!.map((s) => s.gain)
.join(),
of(trackItemsBus(song.id, t)).gain,
],
]),
);
}
it('lets a soloed track be heard with what goes into it, through the tracks it goes through', () => {
expect(heard((tracks) => tracks)).toEqual({ a: ['1', 1], b: ['1', 1], c: ['1', 1], d: ['1', 1] });
// B passes A on, its own items silent; C goes into A and is heard; D has nothing to do with A.
expect(heard(([a, ...rest]) => [{ ...a!, solo: true }, ...rest])).toEqual({
a: ['1', 1],
b: ['1', 0],
c: ['1', 1],
d: ['0', 0],
});
});
it('sends nothing past a soloed track that a track goes into besides', () => {
// A and B into C and into the output, C into the output: with C soloed, A and B only reach it.
const routes: [string, string][] = [
['a', 'c'],
['a', ''],
['b', 'c'],
['b', ''],
['c', ''],
];
expect(heard(([a, b, c, d]) => [a!, b!, { ...c!, solo: true }, d!], routes)).toEqual({
a: ['1,0', 1],
b: ['1,0', 1],
c: ['1', 1],
d: ['', 0],
});
});
it('silences everything a muted track passes on, also what comes into it', () => {
expect(heard(([a, b, ...rest]) => [a!, { ...b!, mute: true }, ...rest])).toEqual({
a: ['1', 1],
b: ['0', 1],
c: ['1', 1],
d: ['1', 1],
});
expect(heard(([a, b, ...rest]) => [{ ...a!, solo: true }, { ...b!, mute: true }, ...rest])).toMatchObject(
{
b: ['0', 0],
},
);
});
it('hears a soloed track even while it is muted', () => {
expect(heard(([a, ...rest]) => [{ ...a!, solo: true, mute: true }, ...rest])).toEqual({
a: ['1', 1],
b: ['1', 0],
c: ['1', 1],
d: ['0', 0],
});
});
it('reads the sends of a song made before #350 as every track into the output', () => {
const tracks = [{ ...newTrack(1), id: 'a' }];
expect(songRoutes({ tracks })).toEqual([{ from: 'a', to: '', volume: 0, tap: 'post' }]);
});
});
describe('the timing of the ways through a song (#350)', () => {
const LATE = 2048 / 48000;
/** A with a Pitch into the output after it and before it; B into A and into the output. */
const buses: Record<string, TimedBus> = {
out: { latency: 0, into: null },
a: {
latency: LATE,
sends: [
{ to: 'out', tap: 'post' },
{ to: 'out', tap: 'preFx' },
],
},
b: {
latency: 0,
sends: [
{ to: 'a', tap: 'post' },
{ to: 'out', tap: 'postFx' },
],
},
items: { latency: 0, into: 'b' },
};
const busOf = (name: string) => buses[name];
it('makes a track as late as its latest way, and holds every other way back to match', () => {
expect(arrivalOf('a', busOf)).toBeCloseTo(LATE, 12);
expect(arrivalOf('b', busOf)).toBeCloseTo(LATE, 12);
expect(arrivalOf('items', busOf)).toBeCloseTo(LATE, 12);
expect(sendDelays('a', busOf)).toEqual([0, expect.closeTo(LATE, 12)]);
expect(sendDelays('b', busOf)).toEqual([0, expect.closeTo(LATE, 12)]);
expect(sendDelays('out', busOf)).toEqual([]);
});
it('counts a loop once instead of running round it', () => {
const looped: Record<string, TimedBus> = {
x: { latency: LATE, sends: [{ to: 'y', tap: 'post' }] },
y: { latency: LATE, sends: [{ to: 'x', tap: 'post' }] },
};
expect(arrivalOf('x', (name) => looped[name])).toBeCloseTo(2 * LATE, 12);
});
});