Route the tracks of a song through a graph (#350)
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s

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:
adminandClaude Opus 5.5 committed 2026-10-07 20:18:09 +02:00
1 parent 3a52f2290f
commit daeffe0f9d
30 files changed
+1323 -104

No files matched your search

+5 -2
View File
@@ -12,6 +12,7 @@ import type {
} from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { defaultTracks } from '../music/song';
import { heldSongGraph } from '../music/routing';
import { linearCurve } from '../model/curve';
import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
@@ -109,8 +110,10 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
case 'pattern':
return { ...base, type: 'pattern', notes: [] };
case 'song':
return { ...base, type: 'song', tracks: defaultTracks(), items: [] };
case 'song': {
const tracks = defaultTracks();
return { ...base, type: 'song', tracks, items: [], graph: heldSongGraph(undefined, tracks) };
}
case 'bus':
return { ...base, type: 'bus' };
case 'particles':
+7 -1
View File
@@ -1,4 +1,5 @@
import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { closesLoop } from '../graph/dropTarget';
import type { GraphLookup } from '../graph/lookup';
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
@@ -50,7 +51,8 @@ export class AddNodeCommand implements Command {
}
execute(project: Project): void {
requireNodeDef(this.node.type);
const def = requireNodeDef(this.node.type);
if (def.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
const graph = requireGraph(project, this.assetId);
// The two terminals give the behavior its signature; a second one of either would make
// "which ports does this behavior have" ambiguous.
@@ -79,6 +81,8 @@ export class RemoveNodeCommand implements Command {
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
const def = nodeDef(findNode(graph, this.nodeId)?.type ?? '');
if (def?.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
@@ -214,6 +218,8 @@ export class ConnectCommand implements Command {
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (closesLoop(graph, this.edge.from.node, this.edge.to.node, lookup))
throw new CommandError('That link would lead round in a loop');
if (graph.edges.some((e) => sameEnds(e, this.edge))) {
// Transitions are told apart by name, so a further one between the same states gets its own.
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
+36 -4
View File
@@ -1,14 +1,17 @@
/**
* Editing a song (#334). As with the notes of a pattern, one gesture is one command: the tracks or
* items are written whole once the pointer lets go, so an undo takes the drag back. What is written
* is held to the rules of a track and an item first; an item whose track goes goes with it.
* is held to the rules of a track and an item first; an item whose track goes goes with it, and the
* routing follows the tracks (#350).
*/
import type { EdgeSend, Graph } from '../graph/Graph';
import type { SongAsset } from '../model/Asset';
import type { Project } from '../model/Project';
import { heldSend, heldSongGraph } from '../music/routing';
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
import { heldLoop, type LoopRange } from '../music/sequence';
import type { Command } from './Command';
import { requireSong } from './require';
import { requireEdge, requireSong } from './require';
export interface SongParts {
tracks?: readonly SongTrack[];
@@ -16,7 +19,7 @@ export interface SongParts {
}
export class SetSongCommand implements Command {
private previous: { tracks: SongTrack[]; items: SongItem[] } | null = null;
private previous: { tracks: SongTrack[]; items: SongItem[]; graph: Graph } | null = null;
constructor(
private readonly assetId: string,
@@ -33,9 +36,10 @@ export class SetSongCommand implements Command {
execute(project: Project): void {
const asset = requireSong(project, this.assetId);
this.previous = { tracks: asset.tracks, items: asset.items };
this.previous = { tracks: asset.tracks, items: asset.items, graph: asset.graph };
asset.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks);
asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks);
asset.graph = heldSongGraph(asset.graph, asset.tracks);
}
undo(project: Project): void {
@@ -43,6 +47,34 @@ export class SetSongCommand implements Command {
const asset = requireSong(project, this.assetId);
asset.tracks = this.previous.tracks;
asset.items = this.previous.items;
asset.graph = this.previous.graph;
}
}
/** How loud a link of a song's routing sends, and where it taps its track (#350). */
export class SetSendCommand implements Command {
readonly label = 'Set send';
private previous: EdgeSend | undefined;
constructor(
private readonly assetId: string,
private readonly edgeId: string,
private readonly send: EdgeSend,
) {}
execute(project: Project): void {
requireSong(project, this.assetId);
const edge = requireEdge(project, this.assetId, this.edgeId);
this.previous = edge.send;
const held = heldSend(this.send);
if (held) edge.send = held;
else delete edge.send;
}
undo(project: Project): void {
const edge = requireEdge(project, this.assetId, this.edgeId);
if (this.previous) edge.send = this.previous;
else delete edge.send;
}
}
+30
View File
@@ -92,6 +92,20 @@ export interface GraphEdge {
folded?: boolean;
/** What a transition between two states is called – the name `State transition` asks for. */
label?: string;
/** On a link of a song's routing (#350): how loud it sends and where it taps. 0 dB after the pan where left out. */
send?: EdgeSend;
}
/**
* Where a send of a song's track takes its sound (#350): after volume and pan, after the sound FX
* but before volume and pan, or right where it comes in, before the sound FX.
*/
export type SendTap = 'post' | 'postFx' | 'preFx';
export interface EdgeSend {
/** In decibels, as a track's volume. */
volume: number;
tap: SendTap;
}
/**
@@ -176,6 +190,22 @@ export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
}
/** Whether a way along the edges leads from node `from` to node `to`; a node reaches itself. */
export function reaches(graph: Pick<Graph, 'edges'>, from: string, to: string): boolean {
const seen = new Set([from]);
const open = [from];
while (open.length > 0) {
const at = open.pop()!;
if (at === to) return true;
for (const edge of graph.edges)
if (edge.from.node === at && !seen.has(edge.to.node)) {
seen.add(edge.to.node);
open.push(edge.to.node);
}
}
return false;
}
/**
* What a compiled graph (shader, particles) depends on and nothing else: node ids, types, params
* and declared ports, and which port feeds which. Positions, collapsing, folding and the boxes are
+4
View File
@@ -149,6 +149,10 @@ export interface NodeDef {
* between whole states, not as signal flowing left to right into a particular socket.
*/
transitionEdges?: true;
/** Comes and goes with something outside the graph, never added or deleted by hand: a song's tracks (#350). */
fixed?: true;
/** Links between such nodes may never lead round to where they started: a song's routing (#350). */
acyclic?: true;
/** The node that comes along when this one is added, `dx` to the right: the end of its block. */
opens?: { type: string; dx: number };
/**
+14 -2
View File
@@ -1,4 +1,4 @@
import { edgesInto, findNode, type Graph, type PortType } from './Graph';
import { edgesInto, findNode, type Graph, type PortType, reaches } from './Graph';
import type { GraphLookup } from './lookup';
import { resolveDef } from './nested';
import { type NodeDef, type PortDef, portsMeet, takesManyEdges } from './NodeRegistry';
@@ -33,7 +33,17 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: G
const toPort = portOf(graph, to, lookup);
if (fromPort === undefined || toPort === undefined) return false;
const [out, inp] = from.isOutput ? [fromPort, toPort] : [toPort, fromPort];
return portsMeet(out, inp) && !linked(graph, from, to);
const [outNode, inNode] = from.isOutput ? [from.nodeId, to.nodeId] : [to.nodeId, from.nodeId];
return portsMeet(out, inp) && !linked(graph, from, to) && !closesLoop(graph, outNode, inNode, lookup);
}
/** Whether a link from `outNode` into `inNode` would lead round to where it started, between nodes that forbid that (#350). */
export function closesLoop(graph: Graph, outNode: string, inNode: string, lookup: GraphLookup): boolean {
const acyclic = (id: string) => {
const node = findNode(graph, id);
return node !== undefined && resolveDef(node, graph, lookup)?.acyclic === true;
};
return acyclic(outNode) && acyclic(inNode) && reaches(graph, inNode, outNode);
}
/**
@@ -52,6 +62,8 @@ export function dropTargetPort(
const def = node && resolveDef(node, graph, lookup);
const fromPort = portOf(graph, from, lookup);
if (!def || fromPort === undefined) return null;
const [outNode, inNode] = from.isOutput ? [from.nodeId, nodeId] : [nodeId, from.nodeId];
if (closesLoop(graph, outNode, inNode, lookup)) return null;
const candidates = compatiblePorts(def, fromPort, from.isOutput)
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, many: takesManyEdges(p) }))
.filter((c) => !linked(graph, from, c.side));
+5 -2
View File
@@ -345,13 +345,14 @@ export interface PatternAsset extends AssetBase {
/**
* Sounds and patterns on tracks along one time axis (#334). The tempo, the meter and the grid are
* properties; the tracks, their items and the loop are the asset's own data, the whole song while
* no loop is set.
* no loop is set. Where each track goes is its graph (#350): a node per track and one output.
*/
export interface SongAsset extends AssetBase {
type: 'song';
tracks: SongTrack[];
items: SongItem[];
loop?: LoopRange;
graph: Graph;
}
/**
@@ -636,7 +637,8 @@ export type GraphAsset =
| ShaderFunctionAsset
| ParticlesAsset
| SoundFxAsset
| InstrumentAsset;
| InstrumentAsset
| SongAsset;
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
'behavior',
@@ -646,6 +648,7 @@ const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
'particles',
'soundfx',
'instrument',
'song',
]);
export function isGraphAsset(asset: Asset): asset is GraphAsset {
+10
View File
@@ -1,3 +1,4 @@
import type { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes';
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
import type { LoopRange, Sequence } from './sequence';
@@ -15,10 +16,19 @@ export interface BusLevel {
pan: number;
/** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */
into?: string;
/** Where a track of a song goes instead (#350): each send on its own, `into` then left out. */
sends?: readonly BusSend[];
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
fx?: FxVoice;
}
/** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */
export interface BusSend {
to?: string;
gain: number;
tap: SendTap;
}
/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */
export interface SequencePlay {
sequence: Sequence;
+218
View File
@@ -0,0 +1,218 @@
/**
* The routing of a song (#350): a graph with a node per track and one output, its links the sends
* from track to track. Everything here is plain arithmetic on that graph – what the sound library
* builds of it lives in `viewport/SoundLibrary.ts`.
*/
import {
createEmptyGraph,
type EdgeSend,
type Graph,
type GraphEdge,
type GraphNode,
reaches,
type SendTap,
} from '../graph/Graph';
import { newId } from '../ids';
import { SONG_OUTPUT, SONG_TRACK, SONG_TRACK_NAME } from './routingNodes';
import { type SongTrack, TRACK_MAX_DB, TRACK_MIN_DB } from './song';
/** The id of the output of every song: no id `newId` makes, so it never meets a track's. */
export const SONG_OUTPUT_ID = '#output';
const TRACK_STEP = 96;
const OUTPUT_DX = 320;
export const SEND_TAPS: readonly { value: SendTap; label: string }[] = [
{ value: 'post', label: 'Post volume (Post pan)' },
{ value: 'postFx', label: 'Pre volume (Post FX)' },
{ value: 'preFx', label: 'Pre volume (Pre FX)' },
];
/** What a link of the routing sends: as its `send` says, else at 0 dB after volume and pan. */
export function sendOf(edge: Pick<GraphEdge, 'send'>): EdgeSend {
return edge.send ?? { volume: 0, tap: 'post' };
}
/** Held to what a send may be; none where it is what a link sends anyway. */
export function heldSend(raw: unknown): EdgeSend | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
const volume =
typeof r.volume === 'number' && Number.isFinite(r.volume)
? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, r.volume))
: 0;
const tap = SEND_TAPS.find((t) => t.value === r.tap)?.value ?? 'post';
return volume === 0 && tap === 'post' ? undefined : { volume, tap };
}
/**
* The routing as it has to be for these tracks: a node for each, named after it, and the one
* output. A track without a node gets one below the others and a link to the output – every track
* of an old song, which had no routing yet. A node or link of nothing that is there goes, and so
* does a link that would close a loop or say again what another one says.
*/
export function heldSongGraph(graph: Graph | undefined, tracks: readonly SongTrack[]): Graph {
const given = graph ?? createEmptyGraph();
const byId = new Map(given.nodes.map((node) => [node.id, node]));
const placed = tracks.flatMap((t) => (byId.get(t.id)?.type === SONG_TRACK ? [byId.get(t.id)!] : []));
const left = placed.length > 0 ? Math.min(...placed.map((n) => n.x)) : 0;
let lowest = placed.length > 0 ? Math.max(...placed.map((n) => n.y)) : -TRACK_STEP;
const fresh: string[] = [];
const trackNodes: GraphNode[] = tracks.map((track) => {
const old = byId.get(track.id);
if (old?.type === SONG_TRACK) return { ...old, params: { ...old.params, [SONG_TRACK_NAME]: track.name } };
fresh.push(track.id);
lowest += TRACK_STEP;
return { id: track.id, type: SONG_TRACK, x: left, y: lowest, params: { [SONG_TRACK_NAME]: track.name } };
});
const oldOutput = byId.get(SONG_OUTPUT_ID);
const middle = trackNodes.reduce((sum, n) => sum + n.y, 0) / Math.max(1, trackNodes.length);
const output: GraphNode =
oldOutput?.type === SONG_OUTPUT
? oldOutput
: { id: SONG_OUTPUT_ID, type: SONG_OUTPUT, x: left + OUTPUT_DX, y: Math.round(middle), params: {} };
const ids = new Set(trackNodes.map((n) => n.id));
const edges: GraphEdge[] = [];
const edgeIds = new Set<string>();
for (const edge of given.edges) {
const from = edge.from.node;
const to = edge.to.node;
const toOutput = to === SONG_OUTPUT_ID;
if (!ids.has(from) || edge.from.port !== 'out' || edge.to.port !== 'in') continue;
if ((!toOutput && !ids.has(to)) || from === to || edgeIds.has(edge.id)) continue;
if (edges.some((e) => e.from.node === from && e.to.node === to)) continue;
if (!toOutput && reaches({ edges }, to, from)) continue;
edgeIds.add(edge.id);
const send = heldSend(edge.send);
edges.push({
id: edge.id,
from: { node: from, port: 'out' },
to: { node: to, port: 'in' },
...(edge.folded && { folded: true }),
...(send && { send }),
});
}
for (const id of fresh)
edges.push({ id: newId(), from: { node: id, port: 'out' }, to: { node: SONG_OUTPUT_ID, port: 'in' } });
return {
nodes: [...trackNodes, output],
edges,
...(given.groups && { groups: given.groups }),
...(given.notes && { notes: given.notes }),
};
}
/** One send of a track: into another track, or `''` into the output. */
export interface SongRoute {
from: string;
to: string;
volume: number;
tap: SendTap;
}
/** The sends of the tracks, as the routing of the song has them. */
export function songRoutes(song: { tracks: readonly SongTrack[]; graph?: Graph }): SongRoute[] {
const graph = heldSongGraph(song.graph, song.tracks);
return graph.edges.map((edge) => ({
from: edge.from.node,
to: edge.to.node === SONG_OUTPUT_ID ? '' : edge.to.node,
...sendOf(edge),
}));
}
/** Whether a track's send into `to` (`''` the output) is open, and whether its own items are heard. */
export interface TrackGate {
passes: (to: string) => boolean;
own: boolean;
}
/**
* Mute and solo along the routing. A muted track passes nothing on, whatever comes in and on every
* send. While some are soloed, a soloed track is heard with all that goes into it; the tracks it
* goes through on its way out pass it on, their own items silent; every other track is silent.
* Only the ways through a soloed track are open: a track that goes into one sends nothing past it.
*/
export function trackGates(
tracks: readonly SongTrack[],
routes: readonly SongRoute[],
): Map<string, TrackGate> {
const soloed = tracks.filter((t) => t.solo).map((t) => t.id);
const gates = new Map<string, TrackGate>();
if (soloed.length === 0) {
for (const track of tracks) gates.set(track.id, { passes: () => !track.mute, own: true });
return gates;
}
const along = (start: readonly string[], next: (id: string) => string[]) => {
const seen = new Set(start);
const open = [...start];
while (open.length > 0)
for (const id of next(open.pop()!))
if (id !== '' && !seen.has(id)) {
seen.add(id);
open.push(id);
}
return seen;
};
const feeding = along(soloed, (id) => routes.filter((r) => r.to === id).map((r) => r.from));
const fed = along(soloed, (id) => routes.filter((r) => r.from === id).map((r) => r.to));
for (const track of tracks)
gates.set(track.id, {
// What a soloed track went through goes on everywhere; the rest only on towards a soloed one.
passes: (to) => !track.mute && (fed.has(track.id) || feeding.has(to)),
own: feeding.has(track.id),
});
return gates;
}
/** What is wrong with a node of the routing, by node id: a track that goes nowhere is never heard. */
export function routingProblems(graph: Graph): Record<string, string> {
const problems: Record<string, string> = {};
for (const node of graph.nodes)
if (node.type === SONG_TRACK && !graph.edges.some((e) => e.from.node === node.id))
problems[node.id] = 'Goes nowhere: link it to the output or to another track to hear it';
return problems;
}
/** A bus as far as its timing goes: how late its sound FX is, and where it goes on to. */
export interface TimedBus {
latency: number;
/** Where it goes as a whole, null straight out; left out where it has sends instead. */
into?: string | null;
sends?: readonly { to: string; tap: SendTap }[];
}
/** How late a send is by its tap: one before the sound FX is not held back by it. */
function tapLatency(bus: TimedBus, tap: SendTap): number {
return tap === 'preFx' ? 0 : bus.latency;
}
/**
* Seconds from where sound goes in on `name` to where it is heard, along the latest of its ways –
* the others are held back to match (`sendDelays`). A bus that goes nowhere is 0; a loop counts once.
*/
export function arrivalOf(name: string, busOf: (name: string) => TimedBus | undefined): number {
const memo = new Map<string, number>();
const walking = new Set<string>();
const arrive = (at: string): number => {
const known = memo.get(at);
if (known !== undefined) return known;
const bus = busOf(at);
if (!bus || walking.has(at)) return 0;
walking.add(at);
const late = bus.sends
? Math.max(0, ...bus.sends.map((s) => tapLatency(bus, s.tap) + arrive(s.to)))
: bus.latency + (bus.into ? arrive(bus.into) : 0);
walking.delete(at);
memo.set(at, late);
return late;
};
return arrive(name);
}
/** How long each send of `bus` is held back, so every way of it is heard as late as the latest one. */
export function sendDelays(name: string, busOf: (name: string) => TimedBus | undefined): number[] {
const bus = busOf(name);
if (!bus?.sends) return [];
const total = arrivalOf(name, busOf);
return bus.sends.map((s) => Math.max(0, total - tapLatency(bus, s.tap) - arrivalOf(s.to, busOf)));
}
+41
View File
@@ -0,0 +1,41 @@
import { registerNode, type NodeDef, type PortDef } from '../graph/NodeRegistry';
import { textOf } from '../model/Properties';
/** The two nodes of a song's routing (#350): one per track, and the one output of the song. */
export const SONG_TRACK = 'SongTrack';
export const SONG_OUTPUT = 'SongOutput';
/** The node param a track's node shows its track's name by, kept in step with the track. */
export const SONG_TRACK_NAME = 'name';
const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio', many: true };
const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' };
export const SongTrackNode = registerNode({
type: SONG_TRACK,
label: 'Track',
category: 'sound',
description:
'A track of the song: its items and whatever comes in, through its sound FX, volume and pan, sent on to every link.',
inputs: [audioIn],
outputs: [audioOut],
params: [],
fixed: true,
acyclic: true,
derivePorts: ({ node }) => ({ label: textOf(node.params[SONG_TRACK_NAME] ?? '') || 'Track' }),
});
export const SongOutputNode = registerNode({
type: SONG_OUTPUT,
label: 'Output',
category: 'sound',
description: 'What the song sounds like: on to the bus of the song.',
inputs: [audioIn],
outputs: [],
params: [],
fixed: true,
acyclic: true,
});
export function isSongRoutingDef(def: NodeDef): boolean {
return def.type === SONG_TRACK || def.type === SONG_OUTPUT;
}
+13 -11
View File
@@ -2,7 +2,8 @@
* A song (#334): sounds and patterns laid out on tracks along one time axis, at one tempo and meter.
* An item is counted in ticks like a note; a pattern item repeats its pattern for as long as it is,
* a sound item its sound, which keeps its own speed whatever the tempo. A track's volume, pan, mute
* and solo are heard on a bus of its own, so a change reaches what is sounding already.
* and solo are heard on a bus of its own, so a change reaches what is sounding already; where it
* goes from there is the song's routing (#350).
*/
import { newId } from '../ids';
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
@@ -127,7 +128,15 @@ export function trackBus(songId: string, trackId: string): string {
return `song:${songId}:${trackId}`;
}
/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track. */
/**
* The bus a track's own items go in by, into the bus of the track (#350): what solo silences on a
* track that only passes a soloed one on. `#` is in no id, so no item's bus is called that.
*/
export function trackItemsBus(songId: string, trackId: string): string {
return `${trackBus(songId, trackId)}:#items`;
}
/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track's items. */
export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): string {
return `${trackBus(songId, item.track)}:${item.id}`;
}
@@ -136,13 +145,6 @@ export function dbToGain(db: number): number {
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
}
/** What a track's bus is set to: silent while muted, or while others are soloed and it is not. */
export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { gain: number; pan: number } {
const soloed = tracks.some((t) => t.solo);
const heard = !track.mute && (!soloed || track.solo);
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
}
/** Whether a pattern item is heard on a bus of its own (#346): it names a sound FX that is there. */
export function hasItemBus(project: Project, item: SongItem): boolean {
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
@@ -186,7 +188,7 @@ export function itemNotes(
return out;
}
/** What the scheduler plays of a song: every item that has its asset, on the bus of its track. */
/** What the scheduler plays of a song: every item that has its asset, on the bus of its track's items. */
export function songSequence(project: Project, song: SongAsset): Sequence {
const notes: SequenceNote[] = [];
const clips: SequenceClip[] = [];
@@ -194,7 +196,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
for (const item of song.items) {
if (!tracks.has(item.track)) continue;
const volume = dbToGain(item.volume);
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackBus(song.id, item.track);
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track);
if (item.kind === 'sound') {
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
clips.push({
+48 -11
View File
@@ -1,27 +1,64 @@
import type { Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import type { Graph } from '../graph/Graph';
import { fxVoiceOf } from './needs';
import type { BusLevel } from './playbacks';
import { hasItemBus, itemBus, type SongItem, songTempo, type SongTrack, trackBus, trackLevel } from './song';
import { songRoutes, trackGates } from './routing';
import {
dbToGain,
hasItemBus,
itemBus,
type SongItem,
songTempo,
type SongTrack,
trackBus,
trackItemsBus,
} from './song';
/**
* The level and pan each track's bus is set to, and the sound FX it has (#346). A pattern item with a
* sound FX has a bus of its own into its track's, at unity: its volume is its notes'. A sound FX that
* does not compile is none, and the item's bus then only passes it on.
* The buses of a song: each track's at its volume and pan, through its sound FX (#346), on to where
* its routing sends it (#350); a bus for its own items into it; and one for each pattern item with a
* sound FX into that, at unity – its volume is its notes'. Mute and solo close sends and items
* (`trackGates`). A sound FX that does not compile is none, and the bus then only passes it on.
*/
export function songBuses(
project: Project,
song: { id: string; tracks: readonly SongTrack[]; items: readonly SongItem[]; props: PropertyBag },
song: {
id: string;
tracks: readonly SongTrack[];
items: readonly SongItem[];
props: PropertyBag;
graph?: Graph;
},
): BusLevel[] {
const tempo = songTempo(song.props);
const fxOf = (id: string) => {
const fx = fxVoiceOf(project, id, tempo);
return fx ? { fx } : {};
};
const tracks = song.tracks.map((track) => ({
name: trackBus(song.id, track.id),
...trackLevel(track, song.tracks),
...fxOf(track.fx),
const routes = songRoutes(song);
const gates = trackGates(song.tracks, routes);
const tracks = song.tracks.map((track) => {
const gate = gates.get(track.id);
return {
name: trackBus(song.id, track.id),
gain: dbToGain(track.volume),
pan: track.pan,
sends: routes
.filter((route) => route.from === track.id)
.map((route) => ({
...(route.to !== '' && { to: trackBus(song.id, route.to) }),
gain: gate?.passes(route.to) ? dbToGain(route.volume) : 0,
tap: route.tap,
})),
...fxOf(track.fx),
};
});
const own = song.tracks.map((track) => ({
name: trackItemsBus(song.id, track.id),
gain: gates.get(track.id)?.own ? 1 : 0,
pan: 0,
into: trackBus(song.id, track.id),
}));
const known = new Set(song.tracks.map((t) => t.id));
const items = song.items
@@ -30,8 +67,8 @@ export function songBuses(
name: itemBus(song.id, item),
gain: 1,
pan: 0,
into: trackBus(song.id, item.track),
into: trackItemsBus(song.id, item.track),
...fxOf(item.fx),
}));
return [...tracks, ...items];
return [...tracks, ...own, ...items];
}
+5
View File
@@ -23,6 +23,8 @@ import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldPatternNotes } from '../music/pattern';
import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing';
import { isSongRoutingDef } from '../music/routingNodes';
import { heldLoop, type LoopRange } from '../music/sequence';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type {
@@ -289,6 +291,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
tracks,
items: heldSongItems(a.items, tracks),
...(loop && { loop }),
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
});
} else if (type === 'curve') {
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
@@ -585,6 +588,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
if (e.folded === true) edge.folded = true;
const label = typeof e.label === 'string' ? e.label.trim() : '';
if (label) edge.label = label;
const send = heldSend(e.send);
if (send) edge.send = send;
graph.edges.push(edge);
}
for (const kind of ANNOTATION_KINDS) {
+9 -4
View File
@@ -25,7 +25,7 @@ import { structTypeUsages } from '../core/model/structTypes';
import { generatesPictures } from '../core/model/generated';
import { isTemplateContent } from '../core/model/instance';
import type { Command } from '../core/commands/Command';
import { findAnnotation } from '../core/graph';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
@@ -159,9 +159,11 @@ export function removeGraphSelection(
): boolean {
const graph = findGraphAsset(state.project, assetId)?.graph;
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
// A node that comes and goes on its own – a track of a song (#350) – stays, the rest goes.
const removable = nodeIds.filter((id) => !nodeDef((graph && findNode(graph, id))?.type ?? '')?.fixed);
const commands: Command[] = [
...links.map((e) => new DisconnectCommand(assetId, e.id)),
...nodeIds.map((id) => new RemoveNodeCommand(assetId, id)),
...removable.map((id) => new RemoveNodeCommand(assetId, id)),
];
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
for (const id of annotationIds) {
@@ -170,11 +172,14 @@ export function removeGraphSelection(
noun = annotationNoun(found.kind);
commands.push(new RemoveAnnotationCommand(assetId, found.kind, id));
}
if (commands.length === 0) return false;
if (commands.length === 0) {
if (nodeIds.length > 0) state.setNotice('These nodes come and go on their own and cannot be deleted');
return false;
}
const label =
commands.length === 1 && noun !== ''
? `Delete ${noun}`
: nodeIds.length === commands.length
: removable.length === commands.length
? `Delete ${commands.length} nodes`
: `Delete ${commands.length} items`;
return state.execute(oneStep(label, commands));
+16
View File
@@ -44,6 +44,22 @@ export function songHooks({ state, editor }: HookContext) {
clock: () => soundLibrary.clock(),
stats: () => soundLibrary.stats(),
buses: () => soundLibrary.busLevels(),
/** Timeline or Routing (#350), and where their switches are. */
shown: () => songEditor.view,
viewAt: (view: 'timeline' | 'routing') =>
centreOf(view === 'routing' ? songEditor.routingButton : songEditor.timelineButton),
routing: {
graph: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'song');
return asset ? (JSON.parse(JSON.stringify(asset.graph)) as unknown) : null;
},
portAt: (nodeId: string, port: string, isOutput: boolean) =>
songEditor.routing.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => songEditor.routing.nodeGlobal(nodeId),
edgeAt: (edgeId: string) => songEditor.routing.edgeGlobal(edgeId),
label: (nodeId: string) => songEditor.routing.nodeLabel(nodeId),
problem: (nodeId: string) => songEditor.routing.nodeProblem(nodeId),
},
},
};
}
+4 -1
View File
@@ -32,6 +32,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { routingProblems } from '../../core/music/routing';
import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
@@ -313,7 +314,9 @@ export class GraphCanvas extends Widget {
? soundFxBuildFor(asset).problems
: asset.type === 'instrument'
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: null;
: asset.type === 'song'
? routingProblems(asset.graph)
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
+2
View File
@@ -138,6 +138,8 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
// A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350).
if (asset.type === 'song') return [];
if (asset.type === 'soundfx') {
const placed = new Set(asset.graph.nodes.map((n) => n.type));
return SOUND_FX_NODES.filter(
+1 -1
View File
@@ -103,7 +103,7 @@ export class GraphWiring {
const graph = this.host.asset()?.graph;
const from = drag.from;
const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project));
if (!graph || fromPort === undefined) return;
if (!graph || fromPort === undefined || this.host.entries().length === 0) return;
const at = this.host.toGraph(global);
this.pendingDrop = drag;
const palette = new NodePalette(
+55 -1
View File
@@ -1,5 +1,16 @@
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { findNode, type GraphEdge, isStateNode, resolveDef, stateName } from '../../core/graph';
import { SetSendCommand } from '../../core/commands/songCommands';
import {
type EdgeSend,
findNode,
type GraphEdge,
isStateNode,
resolveDef,
stateName,
} from '../../core/graph';
import { SEND_TAPS, sendOf } from '../../core/music/routing';
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../core/music/song';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
@@ -9,6 +20,22 @@ import { createFieldRow, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
const NAME_KEY = 'transition-name';
const SEND_VOLUME: PropertyDef = {
key: 'send-volume',
label: 'Volume',
kind: 'number',
default: 0,
min: TRACK_MIN_DB,
max: TRACK_MAX_DB,
step: 0.5,
};
const SEND_TAP: PropertyDef = {
key: 'send-tap',
label: 'Mode',
kind: 'enum',
default: 'post',
options: SEND_TAPS.map((tap) => ({ value: tap.value, label: tap.label })),
};
/** What the panel says about one link: the two ends by the names drawn on them. */
export interface EdgeDescription {
@@ -20,6 +47,8 @@ export interface EdgeDescription {
/** What flows through it, as the output names it. */
type: string;
name: string;
/** On a link of a song's routing (#350): what it sends; null on any other link. */
send: EdgeSend | null;
}
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
@@ -49,6 +78,7 @@ export function describeEdge(project: Project, assetId: string, edgeId: string):
input: to.port,
type: from.type,
name: edge.label ?? '',
send: findGraphAsset(project, assetId)?.type === 'song' ? sendOf(edge) : null,
};
}
@@ -99,6 +129,26 @@ export function buildEdgeSection(
host.fields.set(NAME_KEY, name);
host.body.add(name.row);
addNote(host, 'A State transition node takes it by this name.', 'small');
} else if (d.send) {
const send = d.send;
// The rows outlive an edit of the other one, so what stands is read when one of them changes.
const set = (next: Partial<EdgeSend>) => {
const now = describeEdge(host.state.project, assetId, edgeId)?.send ?? send;
void host.state.execute(new SetSendCommand(assetId, edgeId, { ...now, ...next }));
};
const volume = createFieldRow(host, SEND_VOLUME, send.volume, {
onInput: () => {},
onChange: (next) => set({ volume: Number(next) }),
});
const tap = createFieldRow(host, SEND_TAP, send.tap, {
onInput: () => {},
onChange: (next) => set({ tap: SEND_TAPS.find((t) => t.value === textOf(next))?.value ?? 'post' }),
});
host.fields.set(SEND_VOLUME.key, volume);
host.fields.set(SEND_TAP.key, tap);
host.body.add(volume.row);
host.body.add(tap.row);
addNote(host, 'In decibels. Mode says where on its track the link takes the sound.', 'small');
} else {
fact('Input', d.input);
fact('Type', d.type);
@@ -110,5 +160,9 @@ export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId:
const d = describeEdge(host.state.project, assetId, edgeId);
if (!d) return false;
host.fields.get(NAME_KEY)?.setValue(d.name);
if (d.send) {
host.fields.get(SEND_VOLUME.key)?.setValue(d.send.volume);
host.fields.get(SEND_TAP.key)?.setValue(d.send.tap);
}
return true;
}
+58 -2
View File
@@ -19,7 +19,7 @@ import type { Asset, SongAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui';
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
@@ -32,6 +32,7 @@ import {
} from '../../viewport/takeRecorder';
import { activateAsset } from '../actions';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { claimTransport, releaseTransport } from '../music/transport';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import type { EditorState } from '../state';
@@ -44,6 +45,9 @@ const INPUT_WIDTH = 104;
/** Where the browser keeps the input picked last. */
const INPUT_KEY = 'game-maker.record-input';
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
export type SongView = 'timeline' | 'routing';
/** A take being recorded: the song it goes into, where, and the input it comes from. */
interface Take {
recorder: TakeRecorder;
@@ -61,6 +65,9 @@ interface Take {
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
* under the one clicked last, sound and item one undo step. Esc throws it away.
*
* Routing (#350) shows the same song as a graph instead: a node per track and the output, the links
* between them the sends, edited as any graph is.
*/
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
/** Public for the check scripts. */
@@ -69,6 +76,12 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly recordButton: Button;
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
readonly meter = new TakeMeter();
/** Public for the check scripts. */
readonly timelineButton: Button;
readonly routingButton: Button;
readonly routing: GraphCanvas;
private shown: SongView = 'timeline';
private readonly pane = new VStack();
/** Heard on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
@@ -102,6 +115,17 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
onClick: () => this.toggleRecord(),
});
this.timelineButton = new Button('Timeline', {
toggle: true,
tooltip: 'The tracks and their items along time',
onClick: () => this.showView('timeline'),
});
this.routingButton = new Button('Routing', {
toggle: true,
tooltip: 'Where each track goes: into the output or through other tracks',
onClick: () => this.showView('routing'),
});
this.routing = new GraphCanvas(state, 'song');
this.inputButton.onClick = () => void this.openInputMenu();
this.gridButton.onClick = () => this.openGridMenu();
const timeline = new SongTimeline(state);
@@ -126,6 +150,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
};
timeline.onDiscardTake = () => this.stop({ discard: true });
this.mount(timeline, [
this.timelineButton,
this.routingButton,
this.playButton,
this.recordButton,
this.inputButton,
@@ -145,6 +171,35 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
return asset.type === 'song';
}
get view(): SongView {
return this.shown;
}
/** Puts the timeline or the routing below the header; the routing is framed the first time it shows. */
showView(view: SongView): void {
this.shown = view;
this.canvas.visible = view === 'timeline';
this.routing.visible = view === 'routing';
this.timelineButton.setToggled(view === 'timeline');
this.routingButton.setToggled(view === 'routing');
this.pane.relayout();
if (view === 'routing') this.routing.show(this.canvas.currentAssetId);
this.syncPlayButton();
}
protected override body(canvas: SongTimeline): Widget {
this.pane.add(canvas, 1);
this.pane.add(this.routing, 1);
this.routing.visible = false;
this.timelineButton.setToggled(true);
return this.pane;
}
protected override fit(): void {
if (this.shown === 'routing') this.routing.fit();
else super.fit();
}
get isPlaying(): boolean {
return this.playingId !== '';
}
@@ -459,8 +514,9 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
}
protected override syncExtras(asset: SongAsset | null): void {
if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? '');
this.fitButton.setEnabled(asset !== null);
this.gridButton.setEnabled(asset !== null);
this.gridButton.setEnabled(asset !== null && this.shown === 'timeline');
this.syncPlayButton();
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
+132 -24
View File
@@ -23,6 +23,8 @@ import {
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
import type { EaseShare } from '../core/model/animation';
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
import type { SendTap } from '../core/graph/Graph';
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
@@ -43,6 +45,9 @@ const GLIDE = 0.03;
/** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */
const DECODE_RATE = 48000;
/** The longest a send of a track is held back to meet the latest way of it (#350). */
const MAX_SEND_DELAY = 1;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01;
/** How finely `peaks` cuts a sound up. */
@@ -402,11 +407,17 @@ export class SoundLibrary implements AudioSink {
*/
async setBuses(levels: readonly BusLevel[]): Promise<void> {
const waits: Promise<void>[] = [];
for (const { name, gain, pan, into, fx } of levels) {
for (const { name, gain, pan, into, sends, fx } of levels) {
if (into !== undefined) this.bus(into);
this.route(name, this.bus(name, into ?? null), into ?? null);
const bus = this.bus(name, into ?? null);
if (sends)
this.setSends(
bus,
sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })),
);
else this.route(name, bus, into ?? null);
this.setBus(name, gain, pan);
waits.push(this.setFx(this.bus(name), fx ?? null));
waits.push(this.setFx(bus, fx ?? null));
}
await Promise.all(waits);
}
@@ -431,22 +442,18 @@ export class SoundLibrary implements AudioSink {
/**
* Seconds what goes in on that bus is late by the time it is heard (#346): its chain and those of
* the buses it goes into. 0 straight out.
* the buses it goes into, along the latest of its sends (#350). 0 straight out.
*/
busLatency(name: string): number {
let late = 0;
const seen = new Set<Bus>();
for (
let bus = this.buses.get(name || MASTER_MIX);
bus && !seen.has(bus);
bus = this.buses.get(bus.into ?? '')
) {
seen.add(bus);
late += bus.chain?.latency ?? 0;
}
return late;
return arrivalOf(name || MASTER_MIX, this.timed);
}
private readonly timed = (name: string): TimedBus | undefined => {
const bus = this.buses.get(name);
if (!bus) return undefined;
return { latency: bus.chain?.latency ?? 0, into: bus.into, ...(bus.sends && { sends: bus.sends }) };
};
/** What each track's bus was last set to, with whether its sound FX is there and how late (test hook). */
busLevels(): {
name: string;
@@ -455,13 +462,17 @@ export class SoundLibrary implements AudioSink {
into: string | null;
fx: boolean;
latency: number;
sends?: { to: string; tap: SendTap; gain: number; delay: number }[];
}[] {
return [...this.buses].map(([name, bus]) => ({
name,
...bus.set,
into: bus.into,
into: bus.sends ? null : bus.into,
fx: bus.chain !== null,
latency: bus.chain?.latency ?? 0,
...(bus.sends && {
sends: bus.sends.map(({ to, tap, gain, delay }) => ({ to, tap, gain, delay: delay.delayTime.value })),
}),
}));
}
@@ -488,6 +499,7 @@ export class SoundLibrary implements AudioSink {
pan: context.createStereoPanner(),
set: { gain: 1, pan: 0 },
into: target,
sends: null,
fx: null,
chain: null,
leaving: 0,
@@ -504,12 +516,78 @@ export class SoundLibrary implements AudioSink {
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
private route(name: string, bus: Bus, into: string | null): void {
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
if (bus.into === target) return;
if (bus.into === target && !bus.sends) return;
this.dropSends(bus);
bus.pan.disconnect();
bus.pan.connect(this.outOf(target));
bus.into = target;
}
/**
* A track of a song on along each of its sends (#350): from its tap, through a delay that makes it
* as late as the latest way of the track, at its level, into the bus it names. Only the levels
* glide where the sends are the same ones as before.
*/
private setSends(bus: Bus, sends: readonly { to: string; gain: number; tap: SendTap }[]): void {
const now = this.audio().currentTime;
const same =
bus.sends?.length === sends.length &&
bus.sends.every((old, i) => old.to === sends[i]!.to && old.tap === sends[i]!.tap);
if (same) {
bus.sends!.forEach((old, i) => {
old.gain = Math.max(0, sends[i]!.gain);
old.level.gain.setTargetAtTime(old.gain, now, GLIDE / 3);
});
return;
}
if (bus.sends) this.dropSends(bus);
else bus.pan.disconnect();
const context = this.audio();
bus.sends = sends.map(({ to, gain, tap }) => {
const delay = context.createDelay(MAX_SEND_DELAY);
const level = context.createGain();
level.gain.value = Math.max(0, gain);
delay.connect(level).connect(this.bus(to).input);
return { to, tap, gain: level.gain.value, delay, level };
});
this.wireTaps(bus);
this.alignSends();
}
/** The node a send takes its sound from: after the pan, after the sound FX, or the input. */
private tapOf(bus: Bus, tap: SendTap): AudioNode {
return tap === 'post' ? bus.pan : tap === 'postFx' ? (bus.chain?.out ?? bus.input) : bus.input;
}
/** Feeds every send from its tap; done again whenever a chain comes or goes, which moves a tap. */
private wireTaps(bus: Bus): void {
for (const send of bus.sends ?? []) this.tapOf(bus, send.tap).connect(send.delay);
}
private dropSends(bus: Bus): void {
for (const send of bus.sends ?? []) {
try {
this.tapOf(bus, send.tap).disconnect(send.delay);
} catch {
// Already cut where its chain was made again.
}
send.delay.disconnect();
send.level.disconnect();
}
bus.sends = null;
}
/** Holds every send back as far as `sendDelays` says, so no way of a track is heard before another. */
private alignSends(): void {
for (const [name, bus] of this.buses) {
if (!bus.sends) continue;
const delays = sendDelays(name, this.timed);
bus.sends.forEach((send, i) => {
send.delay.delayTime.value = Math.min(MAX_SEND_DELAY, delays[i] ?? 0);
});
}
}
/**
* The master and the buses of the project (#325): each into the bus it names, at its level unless
* a running game set another, through its sound FX. A bus whose asset is gone is let go. Applied
@@ -601,12 +679,15 @@ export class SoundLibrary implements AudioSink {
const fx = bus.fx;
if (!fx) return;
const context = this.audio();
const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [] };
const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [], out: bus.input };
bus.input.disconnect();
const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made);
out.connect(bus.gain);
made.latency = latency;
made.out = out;
bus.chain = made;
this.wireTaps(bus);
this.alignSends();
}
private unchain(bus: Bus): void {
@@ -617,6 +698,8 @@ export class SoundLibrary implements AudioSink {
for (const node of chain.nodes) node.disconnect();
bus.input.disconnect();
bus.input.connect(bus.gain);
this.wireTaps(bus);
this.alignSends();
}
/**
@@ -725,6 +808,7 @@ export class SoundLibrary implements AudioSink {
}
private dropBus(name: string, bus: Bus): void {
this.dropSends(bus);
this.unchain(bus);
bus.input.disconnect();
bus.gain.disconnect();
@@ -799,15 +883,25 @@ export class SoundLibrary implements AudioSink {
master.set = { gain: 0, pan: 0 };
master.fx = fx;
const names = new Set(tracks.map((track) => track.name));
const ordered = [...tracks].sort((a, b) => Number(a.into !== undefined) - Number(b.into !== undefined));
for (const { name, gain, pan, into, fx: trackFx } of ordered) {
const parent = into !== undefined && names.has(into) ? groupBus(id, into) : masterName;
const track = this.bus(groupBus(id, name), parent);
const inside = (bus: string | undefined) =>
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;
const made = tracks.map(({ name, gain, pan, fx: trackFx }) => {
const track = this.bus(groupBus(id, name), masterName);
track.gain.gain.value = Math.max(0, gain);
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
track.fx = trackFx ?? null;
}
return track;
});
tracks.forEach(({ name, into, sends }, i) => {
const track = made[i]!;
if (sends)
this.setSends(
track,
sends.map((send) => ({ ...send, to: inside(send.to) })),
);
else this.route(groupBus(id, name), track, inside(into));
});
this.groups.add(id);
}
@@ -1784,14 +1878,17 @@ function groupBus(id: number, track: string): string {
/**
* A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan,
* then the bus it goes into or straight out; `set` is what level and pan glide to.
* then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and
* pan glide to.
*/
interface Bus {
input: GainNode;
gain: GainNode;
pan: StereoPannerNode;
set: { gain: number; pan: number };
/** Where it goes as a whole while it has no sends; null straight out. */
into: string | null;
sends: BusSendNodes[] | null;
/** The sound FX it is set to, and the chain made of it once its worklet is there. */
fx: FxVoice | null;
chain: BusChain | null;
@@ -1799,8 +1896,19 @@ interface Bus {
leaving: number;
}
/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */
interface BusSendNodes {
to: string;
tap: SendTap;
gain: number;
delay: DelayNode;
level: GainNode;
}
/** The sound FX of a bus as nodes (#346): one chain everything on the bus goes through. */
interface BusChain {
/** What comes out of it, on to the level of the bus: where a send after the sound FX taps. */
out: AudioNode;
/** In the order `levelsOf` lists the numbers of its chain. */
knobs: Knob[];
/** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */
+6 -2
View File
@@ -91,11 +91,15 @@ export class Music {
/**
* The tracks of a song set to their level and pan, heard at once (#334), and to their sound FX
* (#346). A start waits for the chains.
* (#346). What goes to the output of the song goes to `output` (#350). A start waits for the chains.
*/
setBuses(levels: readonly BusLevel[]): void {
const output = this.output();
this.buses = levels.map((level) => ({ ...level, into: level.into ?? output }));
this.buses = levels.map(({ sends, ...level }) =>
sends
? { ...level, sends: sends.map((send) => ({ ...send, to: send.to ?? output })) }
: { ...level, into: level.into ?? output },
);
this.busesReady = soundLibrary.setBuses(this.buses);
}
+13 -8
View File
@@ -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],
]);
});
+4 -2
View File
@@ -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`).
+2 -1
View File
@@ -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],
+296
View File
@@ -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
View File
@@ -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);
});
});
+1
View File
@@ -188,6 +188,7 @@ describe('a selection of links', () => {
input: 'b',
type: 'number',
name: '',
send: null,
});
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
+138
View File
@@ -1979,6 +1979,144 @@ describe('a sound FX on a bus (#346)', () => {
});
});
describe('the sends of the tracks of a song (#350)', () => {
const LATE = 2048 / 48000;
const voice = (plan: FxPlan): FxVoice => ({ plan, params: {}, tempo: 120 });
const up: FxPlan = { pitch: [{ value: 12 }], stages: [] };
const crush: FxPlan = planOf({ pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
const out = { name: 'song:s:o', gain: 1, pan: 0 };
const sendsOf = (library: SoundLibrary, name: string) =>
library.busLevels().find((bus) => bus.name === name)?.sends;
it('taps each send where its mode says: after the pan, after the sound FX, or where it comes in', async () => {
const library = new SoundLibrary();
await library.setBuses([
out,
{
name: 'song:s:t',
gain: 0.5,
pan: 0,
fx: voice(crush),
sends: [
{ to: 'song:s:o', gain: 1, tap: 'post' },
{ to: 'song:s:o', gain: 0.25, tap: 'postFx' },
{ to: 'song:s:o', gain: 0.5, tap: 'preFx' },
],
},
]);
expect(wires).toEqual(
expect.arrayContaining(['panner > delay', 'crush > delay', 'gain > delay', 'delay > gain']),
);
expect(sendsOf(library, 'song:s:t')).toEqual([
{ to: 'song:s:o', tap: 'post', gain: 1, delay: 0 },
{ to: 'song:s:o', tap: 'postFx', gain: 0.25, delay: 0 },
{ to: 'song:s:o', tap: 'preFx', gain: 0.5, delay: 0 },
]);
// A track with sends is not also sent on as a whole.
expect(library.busLevels().find((bus) => bus.name === 'song:s:t')?.into).toBeNull();
});
it('holds back the ways that pass a Pitch by, so all of a track is heard as late as its latest way', async () => {
const library = new SoundLibrary();
await library.setBuses([
out,
{
name: 'song:s:t',
gain: 1,
pan: 0,
fx: voice(up),
sends: [
{ to: 'song:s:o', gain: 1, tap: 'post' },
{ to: 'song:s:o', gain: 1, tap: 'preFx' },
],
},
{
name: 'song:s:u',
gain: 1,
pan: 0,
sends: [
{ to: 'song:s:t', gain: 1, tap: 'post' },
{ to: 'song:s:o', gain: 1, tap: 'post' },
],
},
]);
expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12);
expect(library.busLatency('song:s:u')).toBeCloseTo(LATE, 12);
expect(sendsOf(library, 'song:s:t')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]);
expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]);
// Without its Pitch nothing of the track is late any more, and nothing is held back.
await library.setBuses([
out,
{
name: 'song:s:t',
gain: 1,
pan: 0,
sends: [
{ to: 'song:s:o', gain: 1, tap: 'post' },
{ to: 'song:s:o', gain: 1, tap: 'preFx' },
],
},
]);
expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, 0]);
});
it('glides the level of a send that only changed its level, and makes the sends anew that go elsewhere', async () => {
const library = new SoundLibrary();
const track = { name: 'song:s:t', gain: 1, pan: 0 };
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 1, tap: 'post' as const }] }]);
const made = delays.length;
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 0, tap: 'post' as const }] }]);
expect(delays).toHaveLength(made);
expect(sendsOf(library, 'song:s:t')).toEqual([{ to: 'song:s:o', tap: 'post', gain: 0, delay: 0 }]);
await library.setBuses([out, { ...track, sends: [{ to: 'mix', gain: 1, tap: 'postFx' as const }] }]);
expect(delays).toHaveLength(made + 1);
expect(disconnects).toEqual(expect.arrayContaining(['delay']));
// Back to one way out as a whole: no sends left.
await library.setBuses([out, track]);
expect(library.busLevels().find((bus) => bus.name === 'song:s:t')).toEqual({
name: 'song:s:t',
gain: 1,
pan: 0,
into: 'mix',
fx: false,
latency: 0,
});
});
it('sends the tracks of a game’s playing into one another and into its master', () => {
const library = new SoundLibrary();
const id = library.playSequence(
'song:s',
{
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60, bus: 't' }] },
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
buses: [
{
name: 't',
gain: 1,
pan: 0,
sends: [
{ to: 'u', gain: 0.5, tap: 'post' },
{ gain: 1, tap: 'preFx' },
],
},
{ name: 'u', gain: 1, pan: 0, sends: [{ gain: 1, tap: 'post' }] },
],
strike: () => 0,
},
'',
);
expect(sendsOf(library, `play:${id}:t`)?.map((send) => [send.to, send.gain, send.tap])).toEqual([
[`play:${id}:u`, 0.5, 'post'],
[`play:${id}`, 1, 'preFx'],
]);
expect(sendsOf(library, `play:${id}:u`)?.map((send) => send.to)).toEqual([`play:${id}`]);
});
});
describe('the master and the buses of the project (#325)', () => {
const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({
name,
+133 -12
View File
@@ -443,6 +443,8 @@ export async function song(t) {
60,
);
const busOf = (track) => `song:${id}:${track}`;
/** Where a track's own items go in, on to its bus (#350). */
const itemsOf = (track) => `${busOf(track)}:#items`;
const firstClip = played.clips[0];
const notes = played.planned.slice(0, 2);
check(
@@ -450,10 +452,10 @@ export async function song(t) {
played.playing &&
Boolean(firstClip) &&
firstClip.sound === SOUND &&
firstClip.bus === busOf(tracks[0]) &&
firstClip.bus === itemsOf(tracks[0]) &&
Math.abs(firstClip.duration - 2) < 1e-9 &&
notes.length === 2 &&
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(tracks[2]) && n.tempo === 120) &&
notes.every((n) => n.instrument === 'sg65-ins' && n.bus === itemsOf(tracks[2]) && n.tempo === 120) &&
Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 &&
Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 &&
notes[0].note === 60,
@@ -469,13 +471,15 @@ export async function song(t) {
JSON.stringify(buses),
);
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
const muted = await waitFor(
() => json('__gm.song.buses()'),
(list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0,
);
// Mute closes every send of the track (#350); its own level stays as it is set.
const closed = (list) => {
const sends = list.find((b) => b.name === busOf(tracks[0]))?.sends ?? [];
return sends.length > 0 && sends.every((send) => send.gain === 0);
};
const muted = await waitFor(() => json('__gm.song.buses()'), closed);
check(
'muting a track while it plays silences its bus at once',
muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')),
'muting a track while it plays closes its sends at once',
closed(muted) && (await json('__gm.song.playing()')),
JSON.stringify(muted),
);
await shot('02-playing');
@@ -603,8 +607,8 @@ export async function song(t) {
run.owner !== '' &&
run.clips.length === 1 &&
gameClip?.sound === SOUND &&
gameClip.bus === busOf(tracks[0]) &&
gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) &&
gameClip.bus === itemsOf(tracks[0]) &&
gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) &&
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6,
@@ -837,8 +841,8 @@ export async function song(t) {
'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time',
fxBus?.fx === true &&
Math.abs(fxBus.latency - frame) < 1e-9 &&
fxNote?.bus === busOf(tracks[1]) &&
plainClip?.bus === busOf(tracks[0]) &&
fxNote?.bus === itemsOf(tracks[1]) &&
plainClip?.bus === itemsOf(tracks[0]) &&
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
Math.abs(clipStart - plainClip.at) < 1e-6,
@@ -855,6 +859,123 @@ export async function song(t) {
JSON.stringify(released),
);
// 65l. Routing (#350): the switch in the header shows the song as a graph, a node per track and
// the output, every track linked to it. A drag from one track's out into another's in sends
// it there, one back would close a loop and is refused, Delete takes a link but not a track.
// A track linked nowhere says it is not heard. Volume and Mode of a link in the inspector
// are what its send is set to when the song plays.
await evaluate('__gm.sound.stopAll()');
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
await sleep(200);
p = await json("__gm.song.viewAt('routing')");
await click(p.x, p.y);
await sleep(300);
const routingOf = () => json(`__gm.song.routing.graph(${JSON.stringify(id)})`);
// The tracks as they are now: more than in 65a.
const ids = (await model()).tracks.map((track) => track.id);
const linksOf = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
const opened65 = await json(`(() => {
const graph = __gm.song.routing.graph(${JSON.stringify(id)});
return { shown: __gm.song.shown(), graph, labels: graph.nodes.map((n) => __gm.song.routing.label(n.id)) };
})()`);
const names = (await model()).tracks.map((track) => track.name);
await shot('05-routing');
check(
'Routing shows a node per track and the output, every track linked to the output',
opened65.shown === 'routing' &&
JSON.stringify(opened65.graph.nodes.map((n) => n.id)) === JSON.stringify([...ids, '#output']) &&
JSON.stringify(opened65.labels) === JSON.stringify([...names, 'Output']) &&
JSON.stringify(linksOf(opened65.graph)) === JSON.stringify(ids.map((track) => `${track}>#output`)),
JSON.stringify(opened65),
);
const port = (nodeId, key, isOutput) =>
json(`__gm.song.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
let fromPort = await port(ids[0], 'out', true);
let toPort = await port(ids[1], 'in', false);
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
await sleep(200);
const linked = await routingOf();
fromPort = await port(ids[1], 'out', true);
toPort = await port(ids[0], 'in', false);
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y);
await sleep(200);
const refused = await routingOf();
check(
'a drag from a track into another links them, one back would close a loop and is refused',
linksOf(linked).includes(`${ids[0]}>${ids[1]}`) &&
linked.edges.length === ids.length + 1 &&
JSON.stringify(refused.edges) === JSON.stringify(linked.edges),
JSON.stringify({ linked: linksOf(linked), refused: linksOf(refused) }),
);
const toOutput = (graph, track) =>
graph.edges.find((e) => e.from.node === track && e.to.node === '#output');
for (const track of [ids[0], ids[2]]) {
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(toOutput(await routingOf(), track).id)})`);
await click(p.x, p.y);
await press('Delete');
await sleep(150);
}
p = await json(`__gm.song.routing.nodeAt(${JSON.stringify(ids[3])})`);
await click(p.x, p.y);
await press('Delete');
await sleep(150);
const trimmed = await routingOf();
const nowhere = await json(`__gm.song.routing.problem(${JSON.stringify(ids[2])})`);
check(
'Delete takes a link but never a track, and a track linked nowhere says it is not heard',
!toOutput(trimmed, ids[0]) &&
!toOutput(trimmed, ids[2]) &&
trimmed.nodes.length === ids.length + 1 &&
String(nowhere).startsWith('Goes nowhere') &&
(await json(`__gm.song.routing.problem(${JSON.stringify(ids[0])})`)) === null,
JSON.stringify({ links: linksOf(trimmed), nodes: trimmed.nodes.length, nowhere }),
);
const send = trimmed.edges.find((e) => e.from.node === ids[0] && e.to.node === ids[1]);
p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(send.id)})`);
await click(p.x, p.y);
await sleep(200);
p = await json("__gm.inspectorField('Volume')");
await click(p.x, p.y);
await t.typeText('-6');
await press('Enter');
await sleep(150);
p = await json("__gm.inspectorField('Mode')");
await click(p.x, p.y);
await sleep(150);
await clickText('Pre volume (Pre FX)');
await sleep(150);
const set = (await routingOf()).edges.find((e) => e.id === send.id);
await shot('06-send');
check(
'Volume and Mode of a link in the inspector set its send',
set?.send?.volume === -6 && set.send.tap === 'preFx',
JSON.stringify(set),
);
p = await json('__gm.song.playAt()');
await click(p.x, p.y);
const sending = await waitFor(
() => json('__gm.song.buses()'),
(list) => list.some((b) => b.name === busOf(ids[0]) && b.sends),
);
const sends = (track) => sending.find((b) => b.name === busOf(track))?.sends;
check(
'playing, each track goes where its links say: through the send set, nowhere for the one without',
sends(ids[0])?.length === 1 &&
sends(ids[0])[0].to === busOf(ids[1]) &&
sends(ids[0])[0].tap === 'preFx' &&
Math.abs(sends(ids[0])[0].gain - 10 ** (-6 / 20)) < 1e-6 &&
sends(ids[1])?.length === 1 &&
sends(ids[2])?.length === 0,
JSON.stringify(sending.filter((b) => b.name.startsWith(`song:${id}:`))),
);
await click(p.x, p.y);
p = await json("__gm.song.viewAt('timeline')");
await click(p.x, p.y);
await sleep(150);
// 65j. The step takes back everything it made.
await evaluate('__gm.sound.stopAll()');
await mutate(`