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>
185 lines
6.7 KiB
TypeScript
185 lines
6.7 KiB
TypeScript
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
|
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';
|
|
import { graphLookup } from '../../core/model/graphLookup';
|
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
|
import { textOf } from '../../core/model/Properties';
|
|
import { Label } from '../../ui';
|
|
import { linkNoun } from '../actions';
|
|
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 {
|
|
transition: boolean;
|
|
from: string;
|
|
output: string;
|
|
to: string;
|
|
input: string;
|
|
/** What flows through it, as the output names it. */
|
|
type: string;
|
|
name: string;
|
|
/** 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 holder = findGraphAsset(project, assetId);
|
|
const graph = holder?.graph;
|
|
const edge = graph?.edges.find((e) => e.id === edgeId);
|
|
if (!holder || !graph || !edge) return null;
|
|
const lookup = graphLookup(project);
|
|
const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => {
|
|
const node = findNode(graph, side.node);
|
|
const def = node && resolveDef(node, graph, lookup);
|
|
const port = def?.[ports].find((p) => p.key === side.port);
|
|
return {
|
|
state: node !== undefined && isStateNode(node),
|
|
node: node ? (isStateNode(node) ? stateName(node) : (def?.label ?? node.type)) : '?',
|
|
port: port ? port.label || port.key : '?',
|
|
type: port?.type ?? '?',
|
|
};
|
|
};
|
|
const from = end(edge.from, 'outputs');
|
|
const to = end(edge.to, 'inputs');
|
|
return {
|
|
transition: from.state && to.state,
|
|
from: from.node,
|
|
output: from.port,
|
|
to: to.node,
|
|
input: to.port,
|
|
type: from.type,
|
|
name: edge.label ?? '',
|
|
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,
|
|
assetId: string,
|
|
edgeId: string,
|
|
edgeIds: readonly string[],
|
|
): string {
|
|
if (edgeIds.length > 1) return `many:${edgeIds.length}`;
|
|
const d = describeEdge(project, assetId, edgeId);
|
|
return d ? [d.transition, d.from, d.output, d.to, d.input, d.type].join('|') : 'missing';
|
|
}
|
|
|
|
/**
|
|
* One link: where it comes from and goes to. Several: how many. Hiding and deleting live in the
|
|
* right-click menu of the line, not here.
|
|
*/
|
|
export function buildEdgeSection(
|
|
host: InspectorHost,
|
|
assetId: string,
|
|
edgeId: string,
|
|
edgeIds: readonly string[],
|
|
): void {
|
|
const noun = linkNoun(host.state, assetId);
|
|
if (edgeIds.length > 1) {
|
|
addTitle(host, `${edgeIds.length} ${noun.toUpperCase()}S`);
|
|
addNote(host, 'Delete removes all of them in one step.');
|
|
return;
|
|
}
|
|
const d = describeEdge(host.state.project, assetId, edgeId);
|
|
if (!d) return;
|
|
addTitle(host, noun.toUpperCase());
|
|
const fact = (label: string, value: string) => {
|
|
const { row } = labelledRow(label);
|
|
row.add(new Label(value), 1);
|
|
host.body.add(row);
|
|
};
|
|
fact('From', d.from);
|
|
if (!d.transition) fact('Output', d.output);
|
|
fact('To', d.to);
|
|
if (d.transition) {
|
|
const name = createFieldRow(host, { key: NAME_KEY, label: 'Name', kind: 'text', default: '' }, d.name, {
|
|
onInput: () => {},
|
|
onChange: (next) => void host.state.execute(new NameTransitionCommand(assetId, edgeId, textOf(next))),
|
|
});
|
|
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) }),
|
|
});
|
|
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_TAP.key, tap);
|
|
host.body.add(tap.row);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** Pushes the current name into its row. False when the link is gone (rebuild). */
|
|
export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId: string): boolean {
|
|
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;
|
|
}
|