Route sounds, songs and buses in one Master routing graph (#351)
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:
1 parent
8891f027db
commit
0e73fd85b9
46 files changed
+1253
-311
No files matched your search
@@ -17,7 +17,9 @@ import {
|
||||
objectSignature,
|
||||
refreshObjectSection,
|
||||
} from './objectSection';
|
||||
import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection';
|
||||
import { buildSongSection, refreshSongSection, songSignature } from './songSection';
|
||||
import { ROUTING_ID } from '../../core/model/Project';
|
||||
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
|
||||
import { INLINE_LIMIT } from '../variables/target';
|
||||
import { inlineListsIn } from '../variables/VariablesEditor';
|
||||
@@ -130,6 +132,7 @@ export class Inspector extends Panel {
|
||||
new Label('Select an asset, a scene object or a node.', { style: 'muted', wrap: true }),
|
||||
);
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node' && s.assetId === ROUTING_ID) buildRoutingNodeSection(this.host, s.nodeId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId);
|
||||
@@ -147,6 +150,8 @@ export class Inspector extends Panel {
|
||||
const s = this.state.selection;
|
||||
if (!s) return '';
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node' && s.assetId === ROUTING_ID)
|
||||
return routingNodeSignature(this.state.project, s.nodeId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId);
|
||||
@@ -176,23 +181,25 @@ export class Inspector extends Panel {
|
||||
const ok =
|
||||
s.kind === 'asset'
|
||||
? refreshAssetSection(this.host, s.assetId)
|
||||
: s.kind === 'node'
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'clip'
|
||||
? refreshClipSection(this.host, s.assetId, s.clipId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
: s.kind === 'node' && s.assetId === ROUTING_ID
|
||||
? refreshRoutingNodeSection(this.host, s.nodeId)
|
||||
: s.kind === 'node'
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'clip'
|
||||
? refreshClipSection(this.host, s.assetId, s.clipId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { SetSendCommand } from '../../core/commands/songCommands';
|
||||
import {
|
||||
type EdgeSend,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
isStateNode,
|
||||
resolveDef,
|
||||
stateName,
|
||||
} from '../../core/graph';
|
||||
import { routedKind, sendsTapped } from '../../core/music/projectRouting';
|
||||
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';
|
||||
@@ -47,15 +49,18 @@ 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. */
|
||||
/** On a link of a song's routing (#350) or of the master routing (#351): what it sends; null on any other link. */
|
||||
send: EdgeSend | null;
|
||||
/** Whether the link may take the sound anywhere but after the level: not out of a sound, an instrument or a pattern. */
|
||||
tapped: boolean;
|
||||
}
|
||||
|
||||
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
|
||||
export function describeEdge(project: Project, assetId: string, edgeId: string): EdgeDescription | null {
|
||||
const graph = findGraphAsset(project, assetId)?.graph;
|
||||
const holder = findGraphAsset(project, assetId);
|
||||
const graph = holder?.graph;
|
||||
const edge = graph?.edges.find((e) => e.id === edgeId);
|
||||
if (!graph || !edge) return null;
|
||||
if (!holder || !graph || !edge) return null;
|
||||
const lookup = graphLookup(project);
|
||||
const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => {
|
||||
const node = findNode(graph, side.node);
|
||||
@@ -78,10 +83,17 @@ 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,
|
||||
send: holder.type === 'song' || holder.type === 'routing' ? sendOf(edge) : null,
|
||||
tapped: holder.type === 'song' || sendsTapped(fromKind(graph, edge)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The asset type a link of the master routing (#351) comes out of; `''` for anything else. */
|
||||
function fromKind(graph: Graph, edge: GraphEdge): string {
|
||||
const node = findNode(graph, edge.from.node);
|
||||
return node ? routedKind(node) : '';
|
||||
}
|
||||
|
||||
/** Structure of a link selection: how many, and for one of them what it joins. */
|
||||
export function edgeSignature(
|
||||
project: Project,
|
||||
@@ -140,15 +152,19 @@ export function buildEdgeSection(
|
||||
onInput: () => {},
|
||||
onChange: (next) => set({ volume: Number(next) }),
|
||||
});
|
||||
host.fields.set(SEND_VOLUME.key, volume);
|
||||
host.body.add(volume.row);
|
||||
if (!d.tapped) {
|
||||
addNote(host, 'In decibels, after the volume and the sound FX of the asset.', 'small');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
addNote(host, 'In decibels. Mode says where on its track or bus the link takes the sound.', 'small');
|
||||
} else {
|
||||
fact('Input', d.input);
|
||||
fact('Type', d.type);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
soundEntries,
|
||||
} from '../../core/properties/assetRef';
|
||||
import { NO_FX } from '../../core/properties/assetFx';
|
||||
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
|
||||
import { MASTER_BUS } from '../../core/properties/buses';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||||
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
|
||||
@@ -403,19 +403,15 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
label: assetRefLabel,
|
||||
choices: (project, def) => {
|
||||
const extras = def.kind === 'asset' ? def : undefined;
|
||||
// Asked as the menu opens: another bus may have been sent into this one meanwhile.
|
||||
const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []);
|
||||
return [
|
||||
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
|
||||
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
|
||||
...assetRefChoices(project, extras?.assetTypes)
|
||||
.filter((asset) => !left.has(asset.id))
|
||||
.flatMap((asset) =>
|
||||
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
|
||||
asset.type === 'sound'
|
||||
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
|
||||
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
|
||||
),
|
||||
...assetRefChoices(project, extras?.assetTypes).flatMap((asset) =>
|
||||
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
|
||||
asset.type === 'sound'
|
||||
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
|
||||
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,9 +26,8 @@ import {
|
||||
TRANSITION_PARAM,
|
||||
variablesSourceOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import {
|
||||
isListNode,
|
||||
@@ -106,7 +105,7 @@ function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string {
|
||||
}
|
||||
|
||||
/** A state machine declares no variables; its one node type has none to name. */
|
||||
const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||||
const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||||
|
||||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||||
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SetMasterFxCommand, SetMasterVolumeCommand } from '../../core/commands/projectCommands';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { MASTER_BUS } from '../../core/properties/buses';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { createFieldRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
|
||||
const MASTER_VOLUME: PropertyDef = {
|
||||
key: 'master-volume',
|
||||
label: 'Volume',
|
||||
kind: 'number',
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
};
|
||||
const MASTER_FX: PropertyDef = {
|
||||
key: 'master-fx',
|
||||
label: 'Sound FX',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['soundfx'],
|
||||
};
|
||||
|
||||
/**
|
||||
* A node of the master routing (#351) stands for its asset, and the inspector shows that asset as
|
||||
* the asset panel would; the master shows its volume and sound FX, those of the project settings.
|
||||
*/
|
||||
export function buildRoutingNodeSection(host: InspectorHost, nodeId: string): void {
|
||||
if (nodeId !== MASTER_BUS) return buildAssetSection(host, nodeId);
|
||||
const project = host.state.project;
|
||||
addTitle(host, 'MASTER');
|
||||
const volume = createFieldRow(host, MASTER_VOLUME, project.masterVolume, {
|
||||
onInput: () => {},
|
||||
onChange: (next) => void host.state.execute(new SetMasterVolumeCommand(Number(next))),
|
||||
});
|
||||
const fx = createFieldRow(host, MASTER_FX, project.masterFx, {
|
||||
onInput: () => {},
|
||||
onChange: (next) => void host.state.execute(new SetMasterFxCommand(textOf(next))),
|
||||
});
|
||||
host.fields.set(MASTER_VOLUME.key, volume);
|
||||
host.fields.set(MASTER_FX.key, fx);
|
||||
host.body.add(volume.row);
|
||||
host.body.add(fx.row);
|
||||
addNote(host, 'Where the game is heard. The same as in Project settings.', 'small');
|
||||
}
|
||||
|
||||
export function routingNodeSignature(project: Project, nodeId: string): string {
|
||||
if (nodeId === MASTER_BUS) return 'master';
|
||||
return findAsset(project, nodeId) ? assetSignature(project, nodeId) : 'missing';
|
||||
}
|
||||
|
||||
/** False when the node's asset is gone (rebuild). */
|
||||
export function refreshRoutingNodeSection(host: InspectorHost, nodeId: string): boolean {
|
||||
if (nodeId !== MASTER_BUS) return refreshAssetSection(host, nodeId);
|
||||
const project = host.state.project;
|
||||
host.fields.get(MASTER_VOLUME.key)?.setValue(project.masterVolume);
|
||||
host.fields.get(MASTER_FX.key)?.setValue(project.masterFx);
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user