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

+5 -26
View File
@@ -124,7 +124,7 @@ import {
} from '../music/song';
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
import { ASSET_FX_KEY } from './assetFx';
import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses';
import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses';
import { Theme } from '../../ui';
/**
@@ -133,9 +133,7 @@ import { Theme } from '../../ui';
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick,
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
* "from the asset" (#352), and `intoFor` names the bus the row is
* the Into of, which leaves out that bus and every one going into it when the picker opens (#325);
* `uploadedOnly` keeps drawn pictures out
* "from the asset" (#352); `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
*/
@@ -149,7 +147,6 @@ type Extras<K extends FlatKind> = K extends 'number'
none?: string;
master?: boolean;
noFx?: boolean;
intoFor?: string;
}
: K extends 'texture'
? { uploadedOnly?: boolean }
@@ -485,16 +482,6 @@ const SLICE_SCALE_ROW: PropertyDef = {
step: 0.1,
};
/** The bus a sound, an instrument, a pattern or a song is heard on (#325). */
const BUS_ROW: PropertyDef = {
key: BUS_KEY,
label: 'Bus',
kind: 'asset',
default: '',
assetTypes: ['bus'],
none: 'Master',
};
/** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
const FX_ROW: PropertyDef = {
key: ASSET_FX_KEY,
@@ -706,12 +693,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
FX_ROW,
BUS_ROW,
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
instrument: [FX_ROW, BUS_ROW],
instrument: [FX_ROW],
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
pattern: [
@@ -744,7 +730,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
step: 1,
},
FX_ROW,
BUS_ROW,
],
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
song: [
@@ -766,13 +751,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
options: SONG_GRID_OPTIONS,
},
FX_ROW,
BUS_ROW,
],
// Where sounds are mixed (#325): how loud, through which sound FX, and on into which bus.
// Where sounds are mixed (#325): how loud and through which sound FX. Where it goes on is the
// master routing's (#351).
bus: [
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
{ ...BUS_ROW, key: BUS_INTO_KEY, label: 'Into' },
],
// How many particles and for how long; what one of them does is the asset's graph, whose Property
// nodes add their rows after these – see `schemaForBag`.
@@ -1121,11 +1105,6 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
// Nothing but declarations: the starting values live in `props` under the very same keys.
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
// A bus never goes into itself, nor into one that already goes into it.
if (asset.type === 'bus')
return FULL_SCHEMAS.bus.map((def) =>
def.key === BUS_INTO_KEY && def.kind === 'asset' ? { ...def, intoFor: asset.id } : def,
);
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
if (asset.type === 'particles')
return sourceRows(
+2 -43
View File
@@ -1,59 +1,18 @@
import { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import { coerceAs } from './kinds';
/** The bus a sound, an instrument, a pattern or a song is heard on (#325); empty is the master. */
export const BUS_KEY = 'bus';
/** How loud a bus is, 0–1. */
export const BUS_VOLUME_KEY = 'volume';
/** The sound FX all of a bus goes through, before its volume. */
export const BUS_FX_KEY = 'fx';
/** The bus a bus goes into; empty is the master. */
export const BUS_INTO_KEY = 'into';
/**
* What a node picks to name the master where empty means something else: "from the asset" on a
* Play node, "no bus" on Set sound FX parameters. No asset id has a `#`.
* Play node, "no bus" on Set sound FX parameters. No asset id has a `#`. It is also the id of the
* master in the master routing (#351).
*/
export const MASTER_BUS = '#master';
export function busOf(bag: PropertyBag): string {
return coerceAs(bag[BUS_KEY], 'asset', '');
}
export function busVolumeOf(bag: PropertyBag): number {
return Math.max(0, Math.min(1, coerceAs(bag[BUS_VOLUME_KEY], 'number', 1)));
}
/** The bus a bus says it goes into, where that is a bus; '' for the master. */
function namedInto(project: Project, busId: string): string {
const bus = findAssetOf(project, busId, 'bus');
const into = bus ? coerceAs(bus.props[BUS_INTO_KEY], 'asset', '') : '';
return into !== '' && findAssetOf(project, into, 'bus') ? into : '';
}
/** Whether following the buses on from `from` comes to `to`. */
function reaches(project: Project, from: string, to: string): boolean {
const seen = new Set<string>();
for (let at = from; at !== '' && !seen.has(at); at = namedInto(project, at)) {
if (at === to) return true;
seen.add(at);
}
return false;
}
/**
* The bus a bus goes into, '' for the master. One that would come round to itself goes into the
* master instead, so however a project was written the buses never run in a loop.
*/
export function busInto(project: Project, busId: string): string {
const into = namedInto(project, busId);
return into !== '' && reaches(project, into, busId) ? '' : into;
}
/** The buses a bus cannot go into: itself and every one that already goes into it. */
export function busesInto(project: Project, busId: string): string[] {
return project.assets
.filter((asset) => asset.type === 'bus' && reaches(project, asset.id, busId))
.map((asset) => asset.id);
}