Route the tracks of a song through a graph (#350)
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:
1 parent
3a52f2290f
commit
daeffe0f9d
30 files changed
+1323
-104
No files matched your search
@@ -25,7 +25,7 @@ import { structTypeUsages } from '../core/model/structTypes';
|
||||
import { generatesPictures } from '../core/model/generated';
|
||||
import { isTemplateContent } from '../core/model/instance';
|
||||
import type { Command } from '../core/commands/Command';
|
||||
import { findAnnotation } from '../core/graph';
|
||||
import { findAnnotation, findNode, nodeDef } from '../core/graph';
|
||||
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||
@@ -159,9 +159,11 @@ export function removeGraphSelection(
|
||||
): boolean {
|
||||
const graph = findGraphAsset(state.project, assetId)?.graph;
|
||||
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
|
||||
// A node that comes and goes on its own – a track of a song (#350) – stays, the rest goes.
|
||||
const removable = nodeIds.filter((id) => !nodeDef((graph && findNode(graph, id))?.type ?? '')?.fixed);
|
||||
const commands: Command[] = [
|
||||
...links.map((e) => new DisconnectCommand(assetId, e.id)),
|
||||
...nodeIds.map((id) => new RemoveNodeCommand(assetId, id)),
|
||||
...removable.map((id) => new RemoveNodeCommand(assetId, id)),
|
||||
];
|
||||
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
|
||||
for (const id of annotationIds) {
|
||||
@@ -170,11 +172,14 @@ export function removeGraphSelection(
|
||||
noun = annotationNoun(found.kind);
|
||||
commands.push(new RemoveAnnotationCommand(assetId, found.kind, id));
|
||||
}
|
||||
if (commands.length === 0) return false;
|
||||
if (commands.length === 0) {
|
||||
if (nodeIds.length > 0) state.setNotice('These nodes come and go on their own and cannot be deleted');
|
||||
return false;
|
||||
}
|
||||
const label =
|
||||
commands.length === 1 && noun !== ''
|
||||
? `Delete ${noun}`
|
||||
: nodeIds.length === commands.length
|
||||
: removable.length === commands.length
|
||||
? `Delete ${commands.length} nodes`
|
||||
: `Delete ${commands.length} items`;
|
||||
return state.execute(oneStep(label, commands));
|
||||
|
||||
@@ -44,6 +44,22 @@ export function songHooks({ state, editor }: HookContext) {
|
||||
clock: () => soundLibrary.clock(),
|
||||
stats: () => soundLibrary.stats(),
|
||||
buses: () => soundLibrary.busLevels(),
|
||||
/** Timeline or Routing (#350), and where their switches are. */
|
||||
shown: () => songEditor.view,
|
||||
viewAt: (view: 'timeline' | 'routing') =>
|
||||
centreOf(view === 'routing' ? songEditor.routingButton : songEditor.timelineButton),
|
||||
routing: {
|
||||
graph: (assetId: string) => {
|
||||
const asset = findAssetOf(state.project, assetId, 'song');
|
||||
return asset ? (JSON.parse(JSON.stringify(asset.graph)) as unknown) : null;
|
||||
},
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
songEditor.routing.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => songEditor.routing.nodeGlobal(nodeId),
|
||||
edgeAt: (edgeId: string) => songEditor.routing.edgeGlobal(edgeId),
|
||||
label: (nodeId: string) => songEditor.routing.nodeLabel(nodeId),
|
||||
problem: (nodeId: string) => songEditor.routing.nodeProblem(nodeId),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||
import { routingProblems } from '../../core/music/routing';
|
||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { ViewTween } from '../viewTween';
|
||||
@@ -313,7 +314,9 @@ export class GraphCanvas extends Widget {
|
||||
? soundFxBuildFor(asset).problems
|
||||
: asset.type === 'instrument'
|
||||
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
|
||||
: null;
|
||||
: asset.type === 'song'
|
||||
? routingProblems(asset.graph)
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
|
||||
@@ -138,6 +138,8 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
||||
// A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350).
|
||||
if (asset.type === 'song') return [];
|
||||
if (asset.type === 'soundfx') {
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
return SOUND_FX_NODES.filter(
|
||||
|
||||
@@ -103,7 +103,7 @@ export class GraphWiring {
|
||||
const graph = this.host.asset()?.graph;
|
||||
const from = drag.from;
|
||||
const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project));
|
||||
if (!graph || fromPort === undefined) return;
|
||||
if (!graph || fromPort === undefined || this.host.entries().length === 0) return;
|
||||
const at = this.host.toGraph(global);
|
||||
this.pendingDrop = drag;
|
||||
const palette = new NodePalette(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { findNode, type GraphEdge, isStateNode, resolveDef, stateName } from '../../core/graph';
|
||||
import { SetSendCommand } from '../../core/commands/songCommands';
|
||||
import {
|
||||
type EdgeSend,
|
||||
findNode,
|
||||
type GraphEdge,
|
||||
isStateNode,
|
||||
resolveDef,
|
||||
stateName,
|
||||
} from '../../core/graph';
|
||||
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';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
@@ -9,6 +20,22 @@ import { createFieldRow, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
|
||||
const NAME_KEY = 'transition-name';
|
||||
const SEND_VOLUME: PropertyDef = {
|
||||
key: 'send-volume',
|
||||
label: 'Volume',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: TRACK_MIN_DB,
|
||||
max: TRACK_MAX_DB,
|
||||
step: 0.5,
|
||||
};
|
||||
const SEND_TAP: PropertyDef = {
|
||||
key: 'send-tap',
|
||||
label: 'Mode',
|
||||
kind: 'enum',
|
||||
default: 'post',
|
||||
options: SEND_TAPS.map((tap) => ({ value: tap.value, label: tap.label })),
|
||||
};
|
||||
|
||||
/** What the panel says about one link: the two ends by the names drawn on them. */
|
||||
export interface EdgeDescription {
|
||||
@@ -20,6 +47,8 @@ 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. */
|
||||
send: EdgeSend | null;
|
||||
}
|
||||
|
||||
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
|
||||
@@ -49,6 +78,7 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +129,26 @@ export function buildEdgeSection(
|
||||
host.fields.set(NAME_KEY, name);
|
||||
host.body.add(name.row);
|
||||
addNote(host, 'A State transition node takes it by this name.', 'small');
|
||||
} else if (d.send) {
|
||||
const send = d.send;
|
||||
// The rows outlive an edit of the other one, so what stands is read when one of them changes.
|
||||
const set = (next: Partial<EdgeSend>) => {
|
||||
const now = describeEdge(host.state.project, assetId, edgeId)?.send ?? send;
|
||||
void host.state.execute(new SetSendCommand(assetId, edgeId, { ...now, ...next }));
|
||||
};
|
||||
const volume = createFieldRow(host, SEND_VOLUME, send.volume, {
|
||||
onInput: () => {},
|
||||
onChange: (next) => set({ volume: Number(next) }),
|
||||
});
|
||||
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');
|
||||
} else {
|
||||
fact('Input', d.input);
|
||||
fact('Type', d.type);
|
||||
@@ -110,5 +160,9 @@ export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId:
|
||||
const d = describeEdge(host.state.project, assetId, edgeId);
|
||||
if (!d) return false;
|
||||
host.fields.get(NAME_KEY)?.setValue(d.name);
|
||||
if (d.send) {
|
||||
host.fields.get(SEND_VOLUME.key)?.setValue(d.send.volume);
|
||||
host.fields.get(SEND_TAP.key)?.setValue(d.send.tap);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui';
|
||||
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { playedBus } from '../../core/music/mixer';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../viewport/takeRecorder';
|
||||
import { activateAsset } from '../actions';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -44,6 +45,9 @@ const INPUT_WIDTH = 104;
|
||||
/** Where the browser keeps the input picked last. */
|
||||
const INPUT_KEY = 'game-maker.record-input';
|
||||
|
||||
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
|
||||
export type SongView = 'timeline' | 'routing';
|
||||
|
||||
/** A take being recorded: the song it goes into, where, and the input it comes from. */
|
||||
interface Take {
|
||||
recorder: TakeRecorder;
|
||||
@@ -61,6 +65,9 @@ interface Take {
|
||||
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
|
||||
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
|
||||
* under the one clicked last, sound and item one undo step. Esc throws it away.
|
||||
*
|
||||
* Routing (#350) shows the same song as a graph instead: a node per track and the output, the links
|
||||
* between them the sends, edited as any graph is.
|
||||
*/
|
||||
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
/** Public for the check scripts. */
|
||||
@@ -69,6 +76,12 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
readonly recordButton: Button;
|
||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||
readonly meter = new TakeMeter();
|
||||
/** Public for the check scripts. */
|
||||
readonly timelineButton: Button;
|
||||
readonly routingButton: Button;
|
||||
readonly routing: GraphCanvas;
|
||||
private shown: SongView = 'timeline';
|
||||
private readonly pane = new VStack();
|
||||
/** Heard on the bus of the asset and the master, as in the game (#325). */
|
||||
readonly music = new Music(
|
||||
() => this.state.project,
|
||||
@@ -102,6 +115,17 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
||||
onClick: () => this.toggleRecord(),
|
||||
});
|
||||
this.timelineButton = new Button('Timeline', {
|
||||
toggle: true,
|
||||
tooltip: 'The tracks and their items along time',
|
||||
onClick: () => this.showView('timeline'),
|
||||
});
|
||||
this.routingButton = new Button('Routing', {
|
||||
toggle: true,
|
||||
tooltip: 'Where each track goes: into the output or through other tracks',
|
||||
onClick: () => this.showView('routing'),
|
||||
});
|
||||
this.routing = new GraphCanvas(state, 'song');
|
||||
this.inputButton.onClick = () => void this.openInputMenu();
|
||||
this.gridButton.onClick = () => this.openGridMenu();
|
||||
const timeline = new SongTimeline(state);
|
||||
@@ -126,6 +150,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
};
|
||||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||||
this.mount(timeline, [
|
||||
this.timelineButton,
|
||||
this.routingButton,
|
||||
this.playButton,
|
||||
this.recordButton,
|
||||
this.inputButton,
|
||||
@@ -145,6 +171,35 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
return asset.type === 'song';
|
||||
}
|
||||
|
||||
get view(): SongView {
|
||||
return this.shown;
|
||||
}
|
||||
|
||||
/** Puts the timeline or the routing below the header; the routing is framed the first time it shows. */
|
||||
showView(view: SongView): void {
|
||||
this.shown = view;
|
||||
this.canvas.visible = view === 'timeline';
|
||||
this.routing.visible = view === 'routing';
|
||||
this.timelineButton.setToggled(view === 'timeline');
|
||||
this.routingButton.setToggled(view === 'routing');
|
||||
this.pane.relayout();
|
||||
if (view === 'routing') this.routing.show(this.canvas.currentAssetId);
|
||||
this.syncPlayButton();
|
||||
}
|
||||
|
||||
protected override body(canvas: SongTimeline): Widget {
|
||||
this.pane.add(canvas, 1);
|
||||
this.pane.add(this.routing, 1);
|
||||
this.routing.visible = false;
|
||||
this.timelineButton.setToggled(true);
|
||||
return this.pane;
|
||||
}
|
||||
|
||||
protected override fit(): void {
|
||||
if (this.shown === 'routing') this.routing.fit();
|
||||
else super.fit();
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingId !== '';
|
||||
}
|
||||
@@ -459,8 +514,9 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: SongAsset | null): void {
|
||||
if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? '');
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
this.gridButton.setEnabled(asset !== null);
|
||||
this.gridButton.setEnabled(asset !== null && this.shown === 'timeline');
|
||||
this.syncPlayButton();
|
||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||||
|
||||
Reference in new issue
Block a user