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>
306 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|