Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab has a second view, Routing: a graph with a node per track and one Output, the links between them sends. A track can go into other tracks, into several at once, and a link has its own volume and the point of the track it taps. - The song asset has a graph like an instrument (SongTrack, SongOutput). heldSongGraph keeps it in step with the tracks on load and in SetSongCommand: a node per track named after it, one output, a new track and every track of an old song linked to the output. Links of gone tracks, doubled links and links closing a loop are dropped. - A link carries an optional send (volume in dB, tap). The inspector shows Volume and Mode for a picked link of a song: Post volume (Post pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand. - NodeDef has two new flags. `fixed` nodes are never added or deleted by hand. Links between `acyclic` nodes may not close a loop, checked in canConnect, dropTargetPort and ConnectCommand. - songBuses gives each track its sends instead of one `into`, and a bus `song:<song>:<track>:#items` for its own items. That bus is what solo closes on a track that only passes a soloed one on. - Mute and solo now act on the sends (trackGates). A muted track passes nothing on, on any tap. A soloed track is heard with what goes into it, through the tracks it goes through, their own items silent. Only sends on a way through a soloed track stay open, so a track going into it and into the output is heard through it only. - SoundLibrary: a bus has either `into` or sends. Each send is tap, delay, level, target. busLatency takes the latest way (arrivalOf), and each send is held back so all ways of a track arrive together (sendDelays). Taps are wired again whenever a chain comes or goes. - The Song tab has Timeline and Routing in its header. The routing uses GraphCanvas with an empty palette. A track linked nowhere shows "Goes nowhere". Delete takes links, never a track node. - Game playings route the same way, the output being the master of the playing. Decided without asking: a send ranges -60 to +12 dB like a track, default 0 dB Post. Mute closes the sends, the track's own level stays. Found on the way: the inspector built the Mode row with the send as it was when the rows were made. Setting Volume and then Mode wrote the old volume back. Both rows now read the current send when they change. Tests: - Unit song-routing (new): 13 green. Sound library: 4 new, 94 green. 11 counter-probes (loop on load, canConnect, solo, solo past the soloed track, mute, preFx latency, graph sync, delete guard, delays, taps, glide), each red. - Existing tests adjusted to the new bus names and send shape: song, bus-fx, song-play, graph registry, edge description. - e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first run caught the inspector bug. Counter-probe with the old closure: 2 FAILED. - Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red (registry rule, edge shape), fixed and rerun locally: 56 green. - Not rerun after the per-send solo fix: e2e 65 and the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a52f2290f
commit
daeffe0f9d
30 files changed
+1323
-104
No files matched your search
@@ -15,7 +15,7 @@ import { soundFxBuildFor } from '../../src/core/soundfx/compile';
|
||||
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
||||
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||
import { itemBus, newItem, newTrack, songSequence, trackBus } from '../../src/core/music/song';
|
||||
import { itemBus, newItem, newTrack, songSequence, trackBus, trackItemsBus } from '../../src/core/music/song';
|
||||
import { songBuses } from '../../src/core/music/songBuses';
|
||||
import { fxChainOf, type FxParams, type FxPlan } from '../../src/core/soundfx/nodes';
|
||||
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||
@@ -104,14 +104,17 @@ describe('a sound FX on the buses of a song (#346)', () => {
|
||||
const f = scene();
|
||||
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||
const [one, two] = f.tracks as [string, string];
|
||||
const out = [{ gain: 1, tap: 'post' }];
|
||||
expect(songBuses(f.project, f.song())).toEqual([
|
||||
{ name: trackBus(f.songId, one), gain: 1, pan: 0, fx: { plan, params: {}, tempo: 120 } },
|
||||
{ name: trackBus(f.songId, two), gain: 1, pan: 0 },
|
||||
{ name: trackBus(f.songId, one), gain: 1, pan: 0, sends: out, fx: { plan, params: {}, tempo: 120 } },
|
||||
{ name: trackBus(f.songId, two), gain: 1, pan: 0, sends: out },
|
||||
{ name: trackItemsBus(f.songId, one), gain: 1, pan: 0, into: trackBus(f.songId, one) },
|
||||
{ name: trackItemsBus(f.songId, two), gain: 1, pan: 0, into: trackBus(f.songId, two) },
|
||||
{
|
||||
name: itemBus(f.songId, f.through),
|
||||
gain: 1,
|
||||
pan: 0,
|
||||
into: trackBus(f.songId, two),
|
||||
into: trackItemsBus(f.songId, two),
|
||||
fx: { plan, params: {}, tempo: 120 },
|
||||
},
|
||||
]);
|
||||
@@ -119,9 +122,9 @@ describe('a sound FX on the buses of a song (#346)', () => {
|
||||
// The item through Wobble on its own bus, the one whose FX is gone on its track's, like the sound.
|
||||
expect(notes.map((n) => [n.tick, n.bus])).toEqual([
|
||||
[0, itemBus(f.songId, f.through)],
|
||||
[BAR, trackBus(f.songId, two)],
|
||||
[BAR, trackItemsBus(f.songId, two)],
|
||||
]);
|
||||
expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackBus(f.songId, two), f.fx.id]]);
|
||||
expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackItemsBus(f.songId, two), f.fx.id]]);
|
||||
});
|
||||
|
||||
it('keeps a pattern item whose sound FX does not compile on a bus of its own that passes it on', () => {
|
||||
@@ -138,7 +141,7 @@ describe('a sound FX on the buses of a song (#346)', () => {
|
||||
name: itemBus(f.songId, f.through),
|
||||
gain: 1,
|
||||
pan: 0,
|
||||
into: trackBus(f.songId, f.tracks[1]!),
|
||||
into: trackItemsBus(f.songId, f.tracks[1]!),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +161,9 @@ describe('a sound FX on the buses of a song (#346)', () => {
|
||||
expect(asked?.play.buses?.map((bus) => [bus.name, bus.into ?? '', 'fx' in bus])).toEqual([
|
||||
[trackBus(f.songId, f.tracks[0]!), '', true],
|
||||
[trackBus(f.songId, f.tracks[1]!), '', false],
|
||||
[itemBus(f.songId, f.through), trackBus(f.songId, f.tracks[1]!), true],
|
||||
[trackItemsBus(f.songId, f.tracks[0]!), trackBus(f.songId, f.tracks[0]!), false],
|
||||
[trackItemsBus(f.songId, f.tracks[1]!), trackBus(f.songId, f.tracks[1]!), false],
|
||||
[itemBus(f.songId, f.through), trackItemsBus(f.songId, f.tracks[1]!), true],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSongRoutingDef } from '../../src/core/music/routingNodes';
|
||||
import { requireNodeDef } from '../../src/core/commands/require';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import {
|
||||
@@ -80,9 +81,10 @@ describe('node registry', () => {
|
||||
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
|
||||
else if (def.category === 'state') expect(def.run).toBeUndefined();
|
||||
// A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a
|
||||
// stage of a voice and an instrument's own node to part of a note: none is ever run.
|
||||
// stage of a voice, an instrument's own node to part of a note and a node of a song's routing
|
||||
// to its buses (#350): none is ever run.
|
||||
else if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound')
|
||||
expect('emit' in def || 'instrument' in def).toBe(true);
|
||||
expect('emit' in def || 'instrument' in def || isSongRoutingDef(def)).toBe(true);
|
||||
// The end of a block runs as part of the node that opens it (`Find` … `Match`).
|
||||
else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined();
|
||||
// Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`).
|
||||
|
||||
@@ -284,7 +284,8 @@ describe('Play song', () => {
|
||||
[Q, 64],
|
||||
]);
|
||||
expect(asked?.play.needs.files).toEqual([FILE]);
|
||||
expect(asked?.play.buses?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([
|
||||
const tracks = asked?.play.buses?.filter((b) => b.sends);
|
||||
expect(tracks?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([
|
||||
[trackBus(f.songId, f.tracks[0]!), 1, 0],
|
||||
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
|
||||
[trackBus(f.songId, f.tracks[2]!), 1, 0],
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
+17
-13
@@ -10,13 +10,14 @@ import {
|
||||
newItem,
|
||||
newTrack,
|
||||
type SongItem,
|
||||
type SongTrack,
|
||||
songGrid,
|
||||
songLength,
|
||||
songLoop,
|
||||
songSequence,
|
||||
trackBus,
|
||||
trackLevel,
|
||||
trackItemsBus,
|
||||
} from '../../src/core/music/song';
|
||||
import { songBuses } from '../../src/core/music/songBuses';
|
||||
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
|
||||
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
|
||||
import { secondsOfTicks } from '../../src/core/music/time';
|
||||
@@ -161,9 +162,9 @@ describe('what a song plays (#334)', () => {
|
||||
[2 * BAR + 960, 960, 64],
|
||||
[3 * BAR, 480, 60],
|
||||
]);
|
||||
expect(notes.every((n) => n.instrument === f.piano && n.bus === trackBus(f.song().id, f.track(0)))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
notes.every((n) => n.instrument === f.piano && n.bus === trackItemsBus(f.song().id, f.track(0))),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => {
|
||||
@@ -202,22 +203,25 @@ describe('what a song plays (#334)', () => {
|
||||
fx: 'fx1',
|
||||
offset: 0.25,
|
||||
volume: 1,
|
||||
bus: trackBus(f.song().id, f.track(2)),
|
||||
bus: trackItemsBus(f.song().id, f.track(2)),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('silences a muted track, and while any track is soloed every one that is not', () => {
|
||||
const f = makeSong();
|
||||
const f = makeSong(2);
|
||||
const [a, b] = f.song().tracks;
|
||||
const level = trackLevel({ ...a!, volume: -6, pan: 0.5 }, f.song().tracks);
|
||||
const sends = (tracks: SongTrack[]) =>
|
||||
songBuses(f.project, { ...f.song(), tracks }).flatMap((bus) =>
|
||||
bus.sends ? [bus.sends.map((s) => s.gain)] : [],
|
||||
);
|
||||
const level = songBuses(f.project, { ...f.song(), tracks: [{ ...a!, volume: -6, pan: 0.5 }, b!] })[0]!;
|
||||
expect(level.gain).toBeCloseTo(0.501, 3);
|
||||
expect(level.pan).toBe(0.5);
|
||||
expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0);
|
||||
const soloed = [{ ...a!, solo: true }, b!];
|
||||
expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]);
|
||||
expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(0);
|
||||
expect(sends([a!, b!])).toEqual([[1], [1]]);
|
||||
expect(sends([{ ...a!, mute: true }, b!])).toEqual([[0], [1]]);
|
||||
expect(sends([{ ...a!, solo: true }, b!])).toEqual([[1], [0]]);
|
||||
});
|
||||
|
||||
it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => {
|
||||
@@ -309,7 +313,7 @@ describe('clips on the scheduler (#334)', () => {
|
||||
);
|
||||
scheduler.start();
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ note: 60, bus: trackBus(f.song().id, f.track(0)) });
|
||||
expect(notes[0]).toMatchObject({ note: 60, bus: trackItemsBus(f.song().id, f.track(0)) });
|
||||
expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user