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

+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. */