Route sounds, songs and buses in one Master routing graph (#351)
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s

A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 09:18:46 +02:00
1 parent 8891f027db
commit 0e73fd85b9
46 files changed
+1253 -311

No files matched your search

+16 -6
View File
@@ -35,7 +35,7 @@ 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 { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
import { limiterLatency } from '../core/soundfx/dynamics';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
@@ -632,6 +632,7 @@ export class SoundLibrary implements AudioSink {
this.cutPan(bus);
bus.pan.connect(this.outOf(target));
bus.into = target;
this.alignSends();
}
/**
@@ -714,9 +715,10 @@ export class SoundLibrary implements AudioSink {
private async applyMix(): Promise<void> {
const names = new Set(this.mix.map((bus) => bus.name));
const waits: Promise<void>[] = [];
for (const { name, volume, into, fx } of this.mix) {
for (const { name, volume, into, sends, fx } of this.mix) {
const bus = this.bus(name, into);
this.route(name, bus, into);
if (sends) this.setSends(bus, sends);
else this.route(name, bus, into);
this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
waits.push(this.setFx(bus, fx));
}
@@ -943,7 +945,7 @@ export class SoundLibrary implements AudioSink {
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++;
const buses = play.buses ?? [];
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX);
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
const tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play;
@@ -992,14 +994,22 @@ export class SoundLibrary implements AudioSink {
/**
* The buses of a playing, its tracks into its master and a pattern item's into its track (#346),
* each with the sound FX it was given. Their chains are made once the worklet is there (`ready`).
* each with the sound FX it was given; its master into `into`, or along `sends` (#351). Their
* chains are made once the worklet is there (`ready`).
*/
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null, into: string): void {
private open(
id: number,
tracks: readonly BusLevel[],
fx: FxVoice | null,
into: string,
sends?: readonly MixSend[],
): void {
const masterName = groupBus(id, '');
const master = this.bus(masterName, into);
master.gain.gain.value = 0;
master.set = { gain: 0, pan: 0 };
master.fx = fx;
if (sends) this.setSends(master, sends);
const names = new Set(tracks.map((track) => track.name));
const inside = (bus: string | undefined) =>
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;