/** * The master routing (#351): a node per sound, instrument, pattern, song, video 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, CreateVideoCommand, 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, videoBusesOf, } 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`; const VIDEO = `${'c3'.repeat(32)}.mp4`; const QUIET = `${'d4'.repeat(32)}.mp4`; /** 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' | 'composition', 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 new bus goes to the master only. 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('hears the sound of a video where its node goes, by its file, and gives one without sound no node', () => { const f = edited(); const reverb = f.create('bus', 'Reverb'); const video = (name: string, file: string, audio: boolean) => { const command = new CreateVideoCommand(name, file, { width: 64, height: 36, duration: 2, fps: 30, audio, }); f.run(command); return command.asset.id; }; const clip = video('Intro', VIDEO, true); const quiet = video('Loop', QUIET, false); const node = f.project.routing.nodes.find((n) => n.id === clip); expect(node?.params).toEqual({ name: 'Intro', kind: 'video' }); expect(f.project.routing.nodes.some((n) => n.id === quiet)).toBe(false); expect(videoBusesOf(f.project)).toEqual( new Map([ [VIDEO, MASTER_MIX], [QUIET, MASTER_MIX], ]), ); // Into one bus alone, then into two: the second time on a bus of its own with both sends. const toMaster = f.project.routing.edges.find((e) => e.from.node === clip)!.id; f.run(new DisconnectCommand(ROUTING_ID, toMaster)); f.link(clip, reverb); expect(videoBusesOf(f.project).get(VIDEO)).toBe(mixBusName(reverb)); f.run(new SetSendCommand(ROUTING_ID, f.link(clip, MASTER_BUS), { volume: -6, tap: 'post' })); expect(videoBusesOf(f.project).get(VIDEO)).toBe(mixBusName(clip)); expect( mixOf(f.project) .find((bus) => bus.name === mixBusName(clip)) ?.sends?.map((s) => s.to), ).toEqual([mixBusName(reverb), MASTER_MIX]); // A second video of the same file plays where the first one goes. video('Intro again', VIDEO, true); expect(videoBusesOf(f.project).get(VIDEO)).toBe(mixBusName(clip)); }); it('link a new video where the other videos go, else to the master, never where the sounds go', () => { const f = edited(); const sfx = f.create('bus', 'SFX'); const film = f.create('bus', 'Film'); const video = (name: string) => { const command = new CreateVideoCommand(name, VIDEO, { width: 64, height: 36, duration: 2, fps: 30, audio: true, }); f.run(command); return command.asset.id; }; const onto = (id: string, bus: string) => { f.run(new DisconnectCommand(ROUTING_ID, f.project.routing.edges.find((e) => e.from.node === id)!.id)); f.link(id, bus); }; for (const name of ['Kick', 'Snare']) onto(f.sound(name), sfx); const intro = video('Intro'); expect(f.linksOf(intro)).toEqual([MASTER_BUS]); onto(intro, film); expect(f.linksOf(video('Outro'))).toEqual([film]); // Nor does the first song go by the videos, though as many of them go to Film as sounds to SFX. expect(f.linksOf(f.create('song', 'Theme'))).toEqual([sfx]); }); 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([]); }); }); describe('a composition in the master routing (#418)', () => { it('comes linked where the other compositions go, else to the master, and is a bus of the mix', () => { const f = edited(); const music = f.create('bus', 'Music'); const onto = (id: string, bus: string) => { f.run(new DisconnectCommand(ROUTING_ID, f.project.routing.edges.find((e) => e.from.node === id)!.id)); f.link(id, bus); }; for (const name of ['Theme', 'Battle']) onto(f.create('song', name), music); const trailer = f.create('composition', 'Trailer'); expect(f.project.routing.nodes.find((n) => n.id === trailer)?.type).toBe(ROUTED_ASSET); // A mix of its own: it goes where the songs go only when linked there. expect(f.linksOf(trailer)).toEqual([MASTER_BUS]); expect(mixOf(f.project).find((bus) => bus.name === mixBusName(trailer))).toMatchObject({ volume: 1, into: MASTER_MIX, fx: null, }); onto(trailer, music); expect(f.linksOf(f.create('composition', 'Teaser'))).toEqual([music]); expect(mixOf(f.project).find((bus) => bus.name === mixBusName(trailer))?.into).toBe(mixBusName(music)); }); });