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) {