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 = { 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 = { 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); }); });