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

+32
View File
@@ -0,0 +1,32 @@
import type { Project } from '../model/Project';
import { type RoutingChange, redoRouting, settleRouting, undoRouting } from '../music/projectRouting';
import type { Command } from './Command';
/**
* Any edit, with the master routing held to the assets after it (#351): a node for an asset that
* came, none for one that went. Taken back and done again along with the edit, so an undone delete
* brings the links of its asset back too.
*/
export class KeepRoutingCommand implements Command {
private change: RoutingChange | null = null;
private settled = false;
constructor(readonly inner: Command) {}
get label(): string {
return this.inner.label;
}
execute(project: Project): void {
this.inner.execute(project);
if (!this.settled) {
this.change = settleRouting(project);
this.settled = true;
} else if (this.change) redoRouting(project, this.change);
}
undo(project: Project): void {
if (this.change) undoRouting(project, this.change);
this.inner.undo(project);
}
}
+13 -5
View File
@@ -6,13 +6,14 @@
*/
import type { EdgeSend, Graph } from '../graph/Graph';
import type { Asset, SongAsset } from '../model/Asset';
import type { Project } from '../model/Project';
import { findGraphAsset, type Project } from '../model/Project';
import { settleEmbedded } from '../music/embedded';
import { routedKind, sendsTapped } from '../music/projectRouting';
import { heldSend, heldSongGraph } from '../music/routing';
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
import { heldLoop, type LoopRange } from '../music/sequence';
import { type Removed, removeWhere, restoreAt } from '../util/list';
import type { Command } from './Command';
import { type Command, CommandError } from './Command';
import { requireEdge, requireSong } from './require';
export interface SongParts {
@@ -75,7 +76,10 @@ export class SetSongCommand implements Command {
}
}
/** How loud a link of a song's routing sends, and where it taps its track (#350). */
/**
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
*/
export class SetSendCommand implements Command {
readonly label = 'Set send';
private previous: EdgeSend | undefined;
@@ -87,10 +91,14 @@ export class SetSendCommand implements Command {
) {}
execute(project: Project): void {
requireSong(project, this.assetId);
const holder = findGraphAsset(project, this.assetId);
if (holder?.type !== 'song' && holder?.type !== 'routing')
throw new CommandError(`Routing ${this.assetId} not found`);
const edge = requireEdge(project, this.assetId, this.edgeId);
const from = holder.graph.nodes.find((n) => n.id === edge.from.node);
const tapped = holder.type === 'song' || (from !== undefined && sendsTapped(routedKind(from)));
this.previous = edge.send;
const held = heldSend(this.send);
const held = heldSend(tapped ? this.send : { ...this.send, tap: 'post' });
if (held) edge.send = held;
else delete edge.send;
}
+24 -1
View File
@@ -6,6 +6,7 @@ import {
isSceneAsset,
type SceneAsset,
} from './Asset';
import { createEmptyGraph, type Graph } from '../graph/Graph';
import { DEFAULT_LAYER_NAME } from './collisionLayers';
import { DEFAULT_MASTER_VOLUME } from './master';
import {
@@ -60,6 +61,11 @@ export interface Project {
*/
masterVolume: number;
masterFx: string;
/**
* Where the sounds, instruments, patterns, songs and buses go (#351): the graph of the Master
* routing tab. A project written before there was one has every one of them on the master.
*/
routing: Graph;
/**
* Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project
* written before there was one reads as 1, the size the icons always had.
@@ -90,6 +96,7 @@ export function createEmptyProject(id: string, name: string): Project {
renderLayers: [DEFAULT_LAYER_NAME],
masterVolume: DEFAULT_MASTER_VOLUME,
masterFx: '',
routing: createEmptyGraph(),
symbolScale: DEFAULT_SYMBOL_SCALE,
playFrom: DEFAULT_PLAY_FROM,
whilePlaying: DEFAULT_WHILE_PLAYING,
@@ -122,7 +129,23 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef
return asset && isSceneAsset(asset) ? asset : undefined;
}
export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined {
/** The id the master routing (#351) goes by where a graph is looked up; no asset id has a `#`. */
export const ROUTING_ID = '#routing';
/** The master routing (#351) where a graph editor and its commands look for an asset's graph. */
export interface RoutingHolder {
id: typeof ROUTING_ID;
type: 'routing';
name: string;
graph: Graph;
}
export type GraphHolder = GraphAsset | RoutingHolder;
/** The graph asset by its id – or the master routing by `ROUTING_ID`, which is no asset. */
export function findGraphAsset(project: Project, assetId: string): GraphHolder | undefined {
if (assetId === ROUTING_ID)
return { id: ROUTING_ID, type: 'routing', name: 'Master routing', graph: project.routing };
const asset = findAsset(project, assetId);
return asset && isGraphAsset(asset) ? asset : undefined;
}
+3 -4
View File
@@ -22,12 +22,11 @@ import { signOf } from './time';
export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx'];
/**
* The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the
* song's. A pattern keeps its tempo and meter (#363), as one of the project does.
* The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern
* keeps its tempo and meter (#363), as one of the project does.
*/
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
sound: ['loop', 'bus'],
pattern: ['bus'],
sound: ['loop'],
};
export function isEmbeddable(type: AssetType): boolean {
+79 -21
View File
@@ -1,20 +1,38 @@
import type { SendTap } from '../graph/Graph';
import { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import { BUS_FX_KEY, busInto, busOf, busVolumeOf, MASTER_BUS } from '../properties/buses';
import { BUS_FX_KEY, busVolumeOf, MASTER_BUS } from '../properties/buses';
import { coerceAs } from '../properties/kinds';
import type { FxVoice } from '../soundfx/nodes';
import { fxVoiceOf } from './needs';
import { isRouted } from './projectRouting';
import { sendOf } from './routing';
import { dbToGain } from './song';
import { DEFAULT_TEMPO } from './time';
/** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */
export const MASTER_MIX = 'mix';
/** One link of the master routing (#351) as the mix plays it: into that bus, at that level, from that tap. */
export interface MixSend {
to: string;
gain: number;
tap: SendTap;
}
/**
* Where something of the project goes (#351): straight into one bus of the mix, as one link at 0 dB
* does, or along its links. No link at all is no send, and silent.
*/
export type MixRoute = string | readonly MixSend[];
/** One bus of the mix as the sound library sets it up: its level, its sound FX and where it goes. */
export interface MixBus {
name: string;
volume: number;
/** The bus it goes into; null for the master, which is heard. */
into: string | null;
/** Where it goes instead, along the links of the routing (#351); `into` then says nothing. */
sends?: readonly MixSend[];
fx: FxVoice | null;
}
@@ -24,29 +42,61 @@ export function mixBusName(busId: string): string {
}
/**
* The master and every bus of the project (#325), each into the bus it names – or the master, where
* that is missing or would make a loop. A sound FX that does not compile is none.
* Where an asset goes by the master routing (#351). One the routing has no node for goes into the
* master, as every asset of a project written before there was a routing did. A link to what is
* neither a bus nor the master counts for nothing.
*/
export function routeOf(project: Project, assetId: string): MixRoute {
const graph = project.routing;
if (!graph.nodes.some((node) => node.id === assetId)) return MASTER_MIX;
const sends = graph.edges.flatMap((edge) => {
const to = edge.to.node;
if (edge.from.node !== assetId || (to !== MASTER_BUS && !findAssetOf(project, to, 'bus'))) return [];
const { volume, tap } = sendOf(edge);
return [{ to: mixBusName(to), gain: dbToGain(volume), tap }];
});
const only = sends.length === 1 ? sends[0]! : null;
return only && only.gain === 1 && only.tap === 'post' ? only.to : sends;
}
/**
* The master and every bus of the project (#325), each where the routing sends it (#351); and a bus
* of its own for each sound and instrument the routing sends anywhere but straight into one bus, for
* all its voices to go through. A sound FX that does not compile is none.
*/
export function mixOf(project: Project): MixBus[] {
const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO);
const buses = project.assets.flatMap((asset) =>
asset.type === 'bus'
? [
{
name: mixBusName(asset.id),
volume: busVolumeOf(asset.props),
into: mixBusName(busInto(project, asset.id)),
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
},
]
: [],
);
const routed = (route: MixRoute) =>
typeof route === 'string' ? { into: route } : { into: MASTER_MIX, sends: route };
const buses = project.assets.flatMap((asset): MixBus[] => {
if (asset.type === 'bus')
return [
{
name: mixBusName(asset.id),
volume: busVolumeOf(asset.props),
...routed(routeOf(project, asset.id)),
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
},
];
if ((asset.type !== 'sound' && asset.type !== 'instrument') || !isRouted(asset)) return [];
const route = routeOf(project, asset.id);
return typeof route === 'string'
? []
: [{ name: mixBusName(asset.id), volume: 1, into: MASTER_MIX, sends: route, fx: null }];
});
return [
{ name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) },
...buses,
];
}
const sameSends = (a: readonly MixSend[] | undefined, b: readonly MixSend[] | undefined) =>
a === b ||
(a !== undefined &&
b !== undefined &&
a.length === b.length &&
a.every((send, i) => send.to === b[i]!.to && send.gain === b[i]!.gain && send.tap === b[i]!.tap));
/** Whether two mixes set the buses up the same: the same plan of a sound FX is the same object. */
export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
return (
@@ -57,6 +107,7 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
bus.name === other.name &&
bus.volume === other.volume &&
bus.into === other.into &&
sameSends(bus.sends, other.sends) &&
bus.fx?.plan === other.fx?.plan &&
bus.fx?.tempo === other.fx?.tempo
);
@@ -65,13 +116,20 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
}
/**
* The bus of the mix something is heard on: the one a node picked, else the one its asset names.
* The bus of the mix a voice of a sound or an instrument is heard on: the one a node picked, else
* where the routing sends the asset (#351) – its own bus, where that is more than one bus.
* A pick naming no bus any more is the master, as a deleted bus would leave it.
*/
export function playedBus(project: Project, picked: string, assetProps: PropertyBag): string {
const id = picked !== '' ? picked : busOf(assetProps);
if (id === MASTER_BUS || id === '') return MASTER_MIX;
return findAssetOf(project, id, 'bus') ? mixBusName(id) : MASTER_MIX;
export function playedBus(project: Project, picked: string, assetId: string): string {
const route = playedRoute(project, picked, assetId);
return typeof route === 'string' ? route : mixBusName(assetId);
}
/** Where a playing of a pattern or a song goes: the bus a node picked, else along its routing (#351). */
export function playedRoute(project: Project, picked: string, assetId: string): MixRoute {
if (picked === MASTER_BUS) return MASTER_MIX;
if (picked !== '') return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : MASTER_MIX;
return routeOf(project, assetId);
}
/** The bus of the mix a node reaches where an empty pick is the master: Set bus volume, Bus volume. */
+3
View File
@@ -1,5 +1,6 @@
import type { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes';
import type { MixSend } from './mixer';
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
import type { LoopRange, Sequence } from './sequence';
@@ -47,6 +48,8 @@ export interface SequencePlay {
fx?: FxVoice;
/** The bus of the mix the whole playing is heard on (#325); the master where left out. */
bus?: string;
/** Where the whole playing goes instead, along the links of the master routing (#351). */
sends?: readonly MixSend[];
/** Starts one note as the player was asked to, and gives back the number of its voice. */
strike(note: ScheduledNote): number;
strikeClip?(clip: ScheduledClip): number;
+240
View File
@@ -0,0 +1,240 @@
/**
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song and
* bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one
* that comes gets a node of its own and a link to the master, one that goes takes its node along.
*/
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, reaches } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { textOf } from '../model/Properties';
import type { Project } from '../model/Project';
import { MASTER_BUS } from '../properties/buses';
import { type Removed, removeWhere, restoreAt } from '../util/list';
import { isEmbedded } from './embedded';
import { heldSend } from './routing';
import { ROUTED_ASSET, ROUTED_KIND, ROUTED_MASTER, ROUTED_NAME } from './routingNodes';
export const ROUTED_TYPES: readonly AssetType[] = ['sound', 'instrument', 'pattern', 'song', 'bus'];
/** What has its own sound FX and level on a bus of its own, and so may send from before them. */
const TAPPED_TYPES: readonly AssetType[] = ['bus', 'song'];
/**
* Where a new node goes: what plays in the first column, by type, the buses in the second, the
* master in the third a row above the rest, so no link into it runs behind a bus.
*/
const COLUMN_STEP = 320;
const ROW_STEP = 80;
const COLUMN_OF = (type: string) => (type === 'bus' ? 1 : 0);
const MASTER_COLUMN = 2;
export function isRouted(asset: Asset): boolean {
return ROUTED_TYPES.includes(asset.type) && !isEmbedded(asset);
}
/** Whether links out of an asset of this type may take the sound before its sound FX or its level. */
export function sendsTapped(type: string): boolean {
return (TAPPED_TYPES as readonly string[]).includes(type);
}
/** The asset type a node of the routing stands for, `''` for the master. */
export function routedKind(node: GraphNode): string {
return node.type === ROUTED_ASSET ? textOf(node.params[ROUTED_KIND] ?? '') : '';
}
/**
* The routing as it has to be for the assets of the project: a node for each, named after it, and
* the master. An asset without a node gets one below the others of its column and a link to the master
* – every asset of a project written before there was a routing. A link goes where one of its ends
* is gone, where it would close a loop or say again what another one says; a node whose every link
* led to a bus that is gone is linked to the master instead, so deleting a bus silences nothing.
* Without `adding`, as a project is read, an asset without a node stays without one – it is heard on
* the master all the same (`routeOf`) – and the master is added only to a routing that has nodes.
*/
export function heldRouting(graph: Graph | undefined, assets: readonly Asset[], adding = true): Graph {
const given = graph ?? createEmptyGraph();
const routed = new Map(assets.filter(isRouted).map((asset) => [asset.id, asset]));
const paramsOf = (asset: Asset) => ({ [ROUTED_NAME]: asset.name, [ROUTED_KIND]: asset.type });
// What stands keeps its place in the list, so a project reads back as it was written.
const nodes: GraphNode[] = given.nodes.flatMap((node) => {
if (node.id === MASTER_BUS) return node.type === ROUTED_MASTER ? [node] : [];
const asset = routed.get(node.id);
return asset && routedKind(node) === asset.type
? [{ ...node, params: { ...node.params, ...paramsOf(asset) } }]
: [];
});
const placed = new Set(nodes.map((node) => node.id));
const lowest = new Map<number, number>();
for (const node of nodes) {
const column = COLUMN_OF(routedKind(node));
if (node.type === ROUTED_ASSET) lowest.set(column, Math.max(lowest.get(column) ?? -Infinity, node.y));
}
const fresh = new Set<string>();
const order = (asset: Asset) => ROUTED_TYPES.indexOf(asset.type);
if (adding)
for (const asset of [...routed.values()].sort((a, b) => order(a) - order(b))) {
if (placed.has(asset.id)) continue;
fresh.add(asset.id);
const column = COLUMN_OF(asset.type);
const y = (lowest.get(column) ?? -ROW_STEP) + ROW_STEP;
lowest.set(column, y);
nodes.push({ id: asset.id, type: ROUTED_ASSET, x: column * COLUMN_STEP, y, params: paramsOf(asset) });
}
if (!placed.has(MASTER_BUS) && (adding || nodes.length > 0)) {
const top = Math.min(0, ...nodes.map((node) => node.y));
nodes.push({
id: MASTER_BUS,
type: ROUTED_MASTER,
x: MASTER_COLUMN * COLUMN_STEP,
y: top - ROW_STEP,
params: {},
});
}
const kinds = new Map(nodes.map((node) => [node.id, routedKind(node)]));
const takesIn = (id: string) => id === MASTER_BUS || kinds.get(id) === 'bus';
const edges: GraphEdge[] = [];
const edgeIds = new Set<string>();
const lost = new Set<string>();
for (const edge of given.edges) {
const from = edge.from.node;
const to = edge.to.node;
const kind = kinds.get(from);
if (!kind || fresh.has(from) || edge.from.port !== 'out') continue;
if (!takesIn(to) || edge.to.port !== 'in') {
lost.add(from);
continue;
}
if (from === to || edgeIds.has(edge.id)) continue;
if (edges.some((e) => e.from.node === from && e.to.node === to)) continue;
if (to !== MASTER_BUS && reaches({ edges }, to, from)) continue;
edgeIds.add(edge.id);
const raw = heldSend(edge.send);
const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw;
edges.push({
id: edge.id,
from: { node: from, port: 'out' },
to: { node: to, port: 'in' },
...(edge.folded && { folded: true }),
...(send && { send }),
});
}
const linked = new Set(edges.map((e) => e.from.node));
for (const node of nodes)
if (fresh.has(node.id) || (lost.has(node.id) && !linked.has(node.id)))
edges.push({ id: newId(), from: { node: node.id, port: 'out' }, to: { node: MASTER_BUS, port: 'in' } });
return {
nodes,
edges,
...(given.groups && { groups: given.groups }),
...(given.notes && { notes: given.notes }),
};
}
/** What one settling of the routing changed, by id, so it can be taken back and done again. */
export interface RoutingChange {
nodes: { added: GraphNode[]; removed: Removed<GraphNode>[]; renamed: RenamedNode[] };
edges: { added: GraphEdge[]; removed: Removed<GraphEdge>[] };
}
interface RenamedNode {
id: string;
before: GraphNode['params'];
after: GraphNode['params'];
}
/**
* Holds the routing of the project to its assets (`heldRouting`), in place: nodes and links stay
* the objects they are, so what an undo of another edit holds on to still finds them. Null where
* nothing had to change.
*/
export function settleRouting(project: Project): RoutingChange | null {
const graph = project.routing;
const held = heldRouting(graph, project.assets);
const heldNodes = new Map(held.nodes.map((n) => [n.id, n]));
const heldEdges = new Map(held.edges.map((e) => [e.id, e]));
const bare = ({ params: _, ...node }: GraphNode) => JSON.stringify(node);
// A node that is another thing now goes and comes anew; one that only reads another name keeps itself.
const removedNodes = new Set(
graph.nodes
.filter((n) => !heldNodes.has(n.id) || bare(heldNodes.get(n.id)!) !== bare(n))
.map((n) => n.id),
);
const removedEdges = new Set(
graph.edges.filter((e) => JSON.stringify(heldEdges.get(e.id)) !== JSON.stringify(e)).map((e) => e.id),
);
const standing = new Set(graph.nodes.map((n) => n.id).filter((id) => !removedNodes.has(id)));
const kept = new Set(graph.edges.map((e) => e.id).filter((id) => !removedEdges.has(id)));
const renamed: RenamedNode[] = graph.nodes.flatMap((node) => {
const after = heldNodes.get(node.id)?.params;
return standing.has(node.id) && after && JSON.stringify(after) !== JSON.stringify(node.params)
? [{ id: node.id, before: node.params, after }]
: [];
});
const change: RoutingChange = {
nodes: { added: held.nodes.filter((n) => !standing.has(n.id)), removed: [], renamed },
edges: { added: held.edges.filter((e) => !kept.has(e.id)), removed: [] },
};
if (
removedNodes.size +
removedEdges.size +
renamed.length +
change.nodes.added.length +
change.edges.added.length ===
0
)
return null;
applyRouting(project, change, removedNodes, removedEdges);
return change;
}
function applyRouting(
project: Project,
change: RoutingChange,
removedNodes: ReadonlySet<string>,
removedEdges: ReadonlySet<string>,
): void {
const graph = project.routing;
change.edges.removed = removeWhere(graph.edges, (e) => removedEdges.has(e.id));
change.nodes.removed = removeWhere(graph.nodes, (n) => removedNodes.has(n.id));
for (const { id, after } of change.nodes.renamed) {
const node = graph.nodes.find((n) => n.id === id);
if (node) node.params = after;
}
graph.nodes.push(...change.nodes.added);
graph.edges.push(...change.edges.added);
}
/** Does a settling again, as it was done the first time. */
export function redoRouting(project: Project, change: RoutingChange): void {
applyRouting(
project,
change,
new Set(change.nodes.removed.map((r) => r.item.id)),
new Set(change.edges.removed.map((r) => r.item.id)),
);
}
/** Takes a settling back: what it added goes, what it took comes back where it was. */
export function undoRouting(project: Project, change: RoutingChange): void {
const graph = project.routing;
const addedNodes = new Set(change.nodes.added.map((n) => n.id));
const addedEdges = new Set(change.edges.added);
graph.edges.splice(0, graph.edges.length, ...graph.edges.filter((e) => !addedEdges.has(e)));
graph.nodes.splice(0, graph.nodes.length, ...graph.nodes.filter((n) => !addedNodes.has(n.id)));
for (const { id, before } of change.nodes.renamed) {
const node = graph.nodes.find((n) => n.id === id);
if (node) node.params = before;
}
restoreAt(graph.nodes, change.nodes.removed);
restoreAt(graph.edges, change.edges.removed);
}
/** What is wrong with a node of the routing, by node id: one that goes nowhere is never heard. */
export function masterRoutingProblems(graph: Graph): Record<string, string> {
const problems: Record<string, string> = {};
for (const node of graph.nodes)
if (node.type === ROUTED_ASSET && !graph.edges.some((e) => e.from.node === node.id))
problems[node.id] = 'Goes nowhere: link it to a bus or to the master to hear it';
return problems;
}
+54
View File
@@ -39,3 +39,57 @@ export const SongOutputNode = registerNode({
export function isSongRoutingDef(def: NodeDef): boolean {
return def.type === SONG_TRACK || def.type === SONG_OUTPUT;
}
/**
* The nodes of the master routing (#351): one per sound, instrument, pattern, song and bus of the
* project, named and typed after its asset, and the master. Only a bus and the master take links in.
*/
export const ROUTED_ASSET = 'RoutedAsset';
export const ROUTED_MASTER = 'RoutedMaster';
export const ROUTED_NAME = 'name';
export const ROUTED_KIND = 'kind';
const KIND_WORDS: Record<string, string> = {
sound: 'Sound',
instrument: 'Instrument',
pattern: 'Pattern',
song: 'Song',
bus: 'Bus',
};
export const RoutedAssetNode = registerNode({
type: ROUTED_ASSET,
label: 'Asset',
category: 'sound',
description:
'A sound, an instrument, a pattern, a song or a bus of the project: all it plays, sent on to every link.',
inputs: [],
outputs: [],
params: [],
fixed: true,
acyclic: true,
derivePorts: ({ node }) => {
const kind = textOf(node.params[ROUTED_KIND] ?? '');
return {
label: textOf(node.params[ROUTED_NAME] ?? '') || 'Asset',
inputs: kind === 'bus' ? [audioIn] : [],
outputs: [{ ...audioOut, label: KIND_WORDS[kind] ?? 'out' }],
};
},
});
export const RoutedMasterNode = registerNode({
type: ROUTED_MASTER,
label: 'Master',
category: 'sound',
description: 'Where the game is heard: through the volume and the sound FX of the master to the speakers.',
inputs: [audioIn],
outputs: [],
params: [],
fixed: true,
acyclic: true,
});
export function isMasterRoutingDef(def: NodeDef): boolean {
return def.type === ROUTED_ASSET || def.type === ROUTED_MASTER;
}
+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);
}
+3 -1
View File
@@ -25,7 +25,8 @@ import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing';
import { isSongRoutingDef } from '../music/routingNodes';
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
import { heldRouting } from '../music/projectRouting';
import { heldLoop, type LoopRange } from '../music/sequence';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type {
@@ -340,6 +341,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
renderLayers: renderLayersOf(r),
masterVolume: masterVolumeOf(r),
masterFx: masterFxOf(r),
routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false),
symbolScale: symbolScaleOf(r),
playFrom: playFromOf(r),
whilePlaying: whilePlayingOf(r),
+27 -12
View File
@@ -1,6 +1,6 @@
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
import { assetTitle } from '../core/music/embedded';
import { mixOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
@@ -94,6 +94,7 @@ import type { PluginManager } from './plugins/loader';
import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor';
export type CentreTab =
| 'scene'
@@ -115,6 +116,7 @@ export type CentreTab =
| 'tile'
| 'container'
| 'variables'
| 'routing'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -123,10 +125,10 @@ export type PluginTabId = `plugin:${string}`;
export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
* opens and closes like one.
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
* (#351) are no documents, but they open and close like one.
*/
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -144,7 +146,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -168,6 +170,7 @@ const PANE_ORDER = [
'tile',
'container',
'variables',
'routing',
'management',
] as const;
@@ -192,6 +195,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
tile: 'Tile',
container: 'Container',
variables: 'Variables',
routing: 'Master routing',
management: 'User management',
};
@@ -202,7 +206,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:');
}
const MIN_LEFT_WIDTH = 160;
@@ -245,6 +249,7 @@ export class Editor implements PlayHost {
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
@@ -335,6 +340,7 @@ export class Editor implements PlayHost {
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -383,7 +389,10 @@ export class Editor implements PlayHost {
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
]),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
@@ -453,6 +462,7 @@ export class Editor implements PlayHost {
game: this.gamePane,
...this.assetEditors,
variables: this.variablesEditor,
routing: this.routingEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -532,7 +542,8 @@ export class Editor implements PlayHost {
// creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`);
@@ -791,6 +802,7 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc);
if (doc === 'routing') this.routingEditor.close();
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene');
@@ -807,8 +819,8 @@ export class Editor implements PlayHost {
if (doc) this.openDocument(doc);
return;
}
if (tab === 'management') {
this.openDocument('management');
if (tab === 'management' || tab === 'routing') {
this.openDocument(tab);
return;
}
if (tab in this.assetEditors) {
@@ -856,7 +868,7 @@ export class Editor implements PlayHost {
const target = this.valueTargets.get(doc);
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management') return 'management';
if (doc === 'management' || doc === 'routing') return doc;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null;
@@ -870,12 +882,14 @@ export class Editor implements PlayHost {
if (kind === 'variables' && target) this.variablesEditor.show(target);
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open();
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing;
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
@@ -885,6 +899,7 @@ export class Editor implements PlayHost {
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
if (doc === 'routing') return ASSET_ICON_SVG.bus;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
@@ -900,7 +915,7 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (!this.tabs.has(doc) && kind)
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
icon: this.iconOf(doc),
onClose: () => this.closeDocument(doc),
});
+4 -2
View File
@@ -105,9 +105,10 @@ export class ProjectSession {
/**
* The one menu of the top bar (#183): everything that is about the project rather than a scene.
* `more` comes last – what the editor adds that is not about the project (#179).
* `project` is what the editor adds about it, after its settings (#351); `more` comes last – what
* the editor adds that is not about the project (#179).
*/
openMainMenu(anchor: Widget, more: MenuEntry[] = []): void {
openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void {
const items: MenuEntry[] = [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
@@ -115,6 +116,7 @@ export class ProjectSession {
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
...project,
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more,
];
+9 -4
View File
@@ -13,12 +13,17 @@ import {
namesBehavior,
SHADER_CALL,
} from '../core/graph';
import type { GraphAsset } from '../core/model/Asset';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance';
import type { Vec2 } from '../core/model/Properties';
import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project';
import {
findGraphAsset,
findObjectHolder,
type GraphHolder,
type ObjectHolder,
type Project,
} from '../core/model/Project';
import { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state';
@@ -158,7 +163,7 @@ export function pasteObjects(
}
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean {
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
@@ -170,7 +175,7 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart(
project: Project,
asset: GraphAsset,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
+24
View File
@@ -0,0 +1,24 @@
import { soundLibrary } from '../../viewport/SoundLibrary';
import { centreOf, type HookContext } from './hookHelpers';
/** The Master routing tab (#351): its graph, where its nodes, ports and links are, and the buses it makes. */
export function routingHooks({ state, editor }: HookContext) {
const { routingEditor } = editor;
const canvas = routingEditor.canvas;
return {
routing: {
shown: () => editor.tab === 'routing' && canvas.currentAssetId !== '',
info: () => routingEditor.shownInfo,
graph: () => JSON.parse(JSON.stringify(state.project.routing)) as unknown,
portAt: (nodeId: string, port: string, isOutput: boolean) => canvas.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => canvas.nodeGlobal(nodeId),
edgeAt: (edgeId: string) => canvas.edgeGlobal(edgeId),
label: (nodeId: string) => canvas.nodeLabel(nodeId),
problem: (nodeId: string) => canvas.nodeProblem(nodeId),
fitAt: () => centreOf(routingEditor.fitButton),
/** The loudest sample on each side of these buses of the mix, tapped on first asking (#359). */
peaks: (names: string[]) => Object.fromEntries(soundLibrary.busPeaks(names)),
buses: () => soundLibrary.busLevels(),
},
};
}
+2
View File
@@ -16,6 +16,7 @@ import { saturationAutoGain } from '../core/soundfx/saturation';
import { curveHooks } from './debug/curveHooks';
import { soundHooks } from './debug/soundHooks';
import { patternHooks } from './debug/patternHooks';
import { routingHooks } from './debug/routingHooks';
import { songHooks } from './debug/songHooks';
import { documentHooks } from './debug/documentHooks';
import { graphHooks } from './debug/graphHooks';
@@ -50,6 +51,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
...curveHooks(context),
...patternHooks(context),
...songHooks(context),
...routingHooks(context),
...soundHooks(context),
...renderHooks(context),
/**
+16 -5
View File
@@ -26,14 +26,16 @@ import {
soundFxParamsMismatch,
startStateOf,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing';
import { findAsset, findGraphAsset } from '../../core/model/Project';
import { ROUTED_ASSET } from '../../core/music/routingNodes';
import { hasAssetEditor } from '../assetTabs';
import { findAsset, findGraphAsset, type GraphHolder } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
import { BeadClock } from './beads';
@@ -281,7 +283,7 @@ export class GraphCanvas extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
private asset(): GraphAsset | null {
private asset(): GraphHolder | null {
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
}
@@ -316,7 +318,9 @@ export class GraphCanvas extends Widget {
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: asset.type === 'song'
? routingProblems(asset.graph)
: null;
: asset.type === 'routing'
? masterRoutingProblems(asset.graph)
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
@@ -429,7 +433,7 @@ export class GraphCanvas extends Widget {
* one held by the selected object's instance. Without a selection nothing is strong – that keeps
* "many instances, many states" readable instead of picking one of them at random.
*/
private stateMarks(asset: GraphAsset): Map<string, 'held' | 'current'> {
private stateMarks(asset: GraphHolder): Map<string, 'held' | 'current'> {
const marks = new Map<string, 'held' | 'current'>();
if (asset.type !== 'statemachine') return marks;
const selection = this.state.selection;
@@ -665,6 +669,13 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
if (node.type === ROUTED_ASSET) {
const asset = findAsset(this.state.project, node.id);
if (!asset || !hasAssetEditor(asset.type)) return false;
this.state.openAsset(node.id);
return true;
}
if (node.type === SHADER_CALL || node.type === INSTRUMENT_SOUND_FX) {
const shader = node.type === SHADER_CALL;
const id = textOf(node.params[shader ? FUNCTION_PARAM : 'fx'] ?? '');
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AddNodeCommand } from '../../core/commands/graphCommands';
import type { GraphAsset } from '../../core/model/Asset';
import type { GraphHolder } from '../../core/model/Project';
import type { Box } from '../framing';
import type { EditorState } from '../state';
import type { NodeView } from './NodeView';
@@ -11,7 +11,7 @@ export interface GraphCanvasHost {
readonly state: EditorState;
/** The asset on the canvas; empty when there is none. */
assetId(): string;
asset(): GraphAsset | null;
asset(): GraphHolder | null;
views(): ReadonlyMap<string, NodeView>;
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
topDownViews(): Iterable<NodeView>;
+5 -5
View File
@@ -21,11 +21,11 @@ import {
STATE_NAME_PARAM,
terminalNode,
} from '../../core/graph';
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import type { GraphHolder, Project } from '../../core/model/Project';
import {
isParticlePropertyNode,
PARTICLE_PROPERTY,
@@ -132,14 +132,14 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
export function paletteEntries(project: Project, asset: GraphHolder): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
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 [];
// A routing holds nodes that come and go on their own: a song's tracks (#350), the assets (#351).
if (asset.type === 'song' || asset.type === 'routing') return [];
if (asset.type === 'soundfx') {
const placed = new Set(asset.graph.nodes.map((n) => n.type));
return SOUND_FX_NODES.filter(
+24 -17
View File
@@ -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();
}
}
+23 -7
View File
@@ -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);
+7 -11
View File
@@ -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) }],
),
];
},
},
+2 -3
View File
@@ -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 {
+62
View File
@@ -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;
}
+3 -3
View File
@@ -24,7 +24,7 @@ import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
import { playedRoute } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
@@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' });
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => {
const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props));
+63
View File
@@ -0,0 +1,63 @@
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { ROUTING_ID } from '../../core/model/Project';
import { Button, Label, Spacer, VStack } from '../../ui';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
const INFO_WIDTH = 220;
/**
* The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song and bus
* and the master, the links between them where each one goes. Edited as the routing of a song is
* (#350); a click on a node shows its asset in the inspector, a double click opens it.
*/
export class RoutingEditor extends VStack {
readonly canvas: GraphCanvas;
readonly fitButton: Button;
readonly snap: SnapToggle;
private readonly info = new Label('', { style: 'small', align: 'right' });
constructor(private readonly state: EditorState) {
super();
this.canvas = new GraphCanvas(state, 'routing');
this.snap = new SnapToggle(() => this.canvas);
this.fitButton = new Button('Fit', { onClick: () => this.canvas.fit() });
this.info.minWidth = INFO_WIDTH;
const header = new TabHeader();
for (const widget of this.snap.widgets) header.add(widget);
header.add(this.fitButton);
header.add(new Spacer(), 1);
header.add(this.info);
this.add(header);
this.add(this.canvas, 1);
state.on('project', () => this.refresh());
state.on('load', () => this.close());
}
/** Shows the routing of the project; framed the first time, as it was left after that. */
open(): void {
this.canvas.show(ROUTING_ID);
this.refresh();
}
/** The tab is closed: opened again, the routing starts from a fitted view. */
close(): void {
this.canvas.show('');
this.refresh();
}
/** The readout: how many links, and how many nodes go nowhere and are never heard. */
get shownInfo(): string {
return this.info.text;
}
private refresh(): void {
if (this.canvas.currentAssetId !== ROUTING_ID) return this.info.setText('');
const graph = this.state.project.routing;
const silent = Object.keys(masterRoutingProblems(graph)).length;
const links = `${graph.edges.length} link${graph.edges.length === 1 ? '' : 's'}`;
this.info.setText(silent > 0 ? `${links} · ${silent} going nowhere` : links);
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ import { assetFxOf } from '../../core/properties/assetFx';
import type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
import { playedRoute } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
type InputDevice,
@@ -92,10 +92,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly snap = new SnapToggle(() => this.routing);
private shown: SongView = 'timeline';
private readonly pane = new VStack();
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => {
const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props));
+2 -2
View File
@@ -152,8 +152,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.stop();
claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352), its bus and the master, as the game hears it (#325).
const bus = playedBus(this.state.project, '', props);
// Through its sound FX (#352) and along its routing (#351), as the game hears it.
const bus = playedBus(this.state.project, '', asset.id);
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
const file = playedSoundOf({ asset, clip });
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
+2 -2
View File
@@ -106,8 +106,8 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } };
// On the bus of the sound, as the game would play it there (#325).
const bus = playedBus(this.state.project, '', props);
// Along the routing of the sound, as the game would play it there (#351).
const bus = playedBus(this.state.project, '', sound.asset.id);
const file = playedSoundOf(sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice);
+5 -1
View File
@@ -28,6 +28,8 @@ import type { GameScale, PlayFrom } from '../core/model/playSettings';
import type { PlaceableAssetType } from '../core/model/AssetTypes';
import type { VisibilityPart } from '../core/model/sceneVisibility';
import type { ValuePath } from '../core/properties/valuePath';
import { KeepRoutingCommand } from '../core/commands/routingCommands';
import { settleRouting } from '../core/music/projectRouting';
export type Selection =
| { kind: 'asset'; assetId: string }
@@ -310,6 +312,8 @@ export class EditorState {
this.paused = false;
this.project = project;
this.revision = revision;
// Every sound, instrument, pattern, song and bus has its node in the Master routing (#351).
settleRouting(project);
this.commands = new CommandStack(project);
this.selection = null;
this.openedValues = null;
@@ -409,7 +413,7 @@ export class EditorState {
*/
execute(command: Command): boolean {
return this.runEdit(() => {
this.commands.execute(command);
this.commands.execute(new KeepRoutingCommand(command));
return true;
});
}
+11 -5
View File
@@ -31,8 +31,9 @@ import {
songTempo,
SOUND_ITEM_MAX_DB,
} from '../../core/music/song';
import type { SequencePlay } from '../../core/music/playbacks';
import { songBuses } from '../../core/music/songBuses';
import { playedBus, reachedBus } from '../../core/music/mixer';
import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer';
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem';
@@ -295,7 +296,7 @@ function playSound(
loop ?? loopsOf(props),
owner,
fx ?? DRY_VOICE,
playedBus(project, bus, props),
playedBus(project, bus, ref.asset.id),
);
}
@@ -306,7 +307,7 @@ export function instrumentFx(project: Project, instrumentId: string, picked: str
/** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */
export function instrumentBus(project: Project, instrumentId: string, picked: string): string {
return playedBus(project, picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
return playedBus(project, picked, instrumentId);
}
/**
@@ -357,6 +358,11 @@ export function instrumentVoiceName(instrumentId: string): string {
return `instrument:${instrumentId}`;
}
/** Where a playing of a pattern or a song goes: into one bus, or along its links (#351). */
function heardOn(route: MixRoute): Pick<SequencePlay, 'bus' | 'sends'> {
return typeof route === 'string' ? { bus: route } : { sends: route };
}
/**
* A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or
* round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner
@@ -390,7 +396,7 @@ function playPattern(
loop: loop ? { start: 0, end: length } : null,
needs: sequenceNeeds(project, sequence),
...(fx && { fx }),
bus: playedBus(project, bus, asset.props),
...heardOn(playedRoute(project, bus, patternId)),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
},
owner,
@@ -431,7 +437,7 @@ function playSong(
fadeIn: heldFade(fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
bus: playedBus(project, bus, song.props),
...heardOn(playedRoute(project, bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
},
+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;
+8 -6
View File
@@ -5,7 +5,7 @@ import {
type SchedulerOptions,
} from '../core/music/Scheduler';
import { sequenceNeeds } from '../core/music/needs';
import { MASTER_MIX } from '../core/music/mixer';
import { MASTER_MIX, type MixRoute } from '../core/music/mixer';
import type { BusLevel } from '../core/music/playbacks';
import type { Sequence } from '../core/music/sequence';
import type { Project } from '../core/model/Project';
@@ -52,13 +52,13 @@ export class Music {
readonly clips: PlannedClip[] = [];
/**
* `output` is the bus of the mix what it plays is heard on (#325): that of the pattern or song in
* the tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of
* `output` is where what it plays goes (#351): along the routing of the pattern or song in the
* tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of
* that pattern or song (#352), on the way there.
*/
constructor(
private readonly project: () => Project,
private readonly output: () => string = () => MASTER_MIX,
private readonly output: () => MixRoute = () => MASTER_MIX,
private readonly fx: () => FxVoice | null = () => null,
) {}
@@ -104,12 +104,14 @@ export class Music {
/**
* Where what goes to the output of the pattern or song is heard: its own bus, set to its sound FX
* as it is now and on into `output`. A key of the roll is played there too.
* as it is now and on along `output`. A key of the roll is played there too.
*/
heardOn(): string {
const fx = this.fx();
const output = this.output();
const route = typeof output === 'string' ? { into: output } : { sends: output };
this.fxReady = soundLibrary.setBuses([
{ name: this.outBus, gain: 1, pan: 0, into: this.output(), ...(fx && { fx }) },
{ name: this.outBus, gain: 1, pan: 0, ...route, ...(fx && { fx }) },
]);
return this.outBus;
}