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;
}
+2 -2
View File
@@ -128,10 +128,10 @@ function gains(layer: FxPlan | undefined): number[] {
}
describe('the Sound FX row (#352)', () => {
it('stands right before Bus on a sound, an instrument, a pattern and a song', () => {
it('stands last on a sound, an instrument, a pattern and a song, where Bus stood before #351', () => {
for (const type of ['sound', 'instrument', 'pattern', 'song'] as AssetType[]) {
const keys = schemaFor(type).map((def) => def.key);
expect(keys.indexOf(ASSET_FX_KEY), type).toBe(keys.indexOf('bus') - 1);
expect(keys.indexOf(ASSET_FX_KEY), type).toBe(keys.length - 1);
expect(schemaFor(type).find((def) => def.key === ASSET_FX_KEY)).toMatchObject({
kind: 'asset',
default: '',
+25 -54
View File
@@ -1,6 +1,6 @@
/**
* Master and buses (#325): which bus a bus goes into, what the sound library is set up with, and
* which bus a sound, a note, a pattern or a song is heard on.
* Master and buses (#325): what the sound library is set up with, and which bus a sound, a note, a
* pattern or a song is heard on – where each goes is the master routing's (#351).
*/
import { describe, expect, it } from 'vitest';
import {
@@ -21,7 +21,7 @@ import {
reachedBus,
sameMix,
} from '../../src/core/music/mixer';
import { BUS_INTO_KEY, BUS_KEY, busesInto, busInto, MASTER_BUS } from '../../src/core/properties/buses';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes';
import { GameRun } from '../../src/runtime/GameRun';
@@ -34,6 +34,7 @@ import {
makeSpriteScene,
place,
roundTrip,
routingOf,
startWorld,
} from '../helpers';
@@ -46,9 +47,8 @@ function withBuses(...names: string[]) {
f.stack.execute(command);
return command.asset.id;
});
const into = (bus: string, target: string) =>
f.stack.execute(new SetAssetPropertyCommand(bus, BUS_INTO_KEY, target));
return { ...f, ids, into };
const routing = routingOf(f);
return { ...f, ids, into: (bus: string, target: string) => routing.only(bus, target) };
}
/** A sound FX that compiles: Input, a Gain, Output. */
@@ -65,56 +65,23 @@ function workingFx(f: ReturnType<typeof makeProject>): string {
}
describe('the bus asset (#325)', () => {
it('is not placeable and starts at full volume, without a sound FX, into the master', () => {
it('is not placeable and starts at full volume, without a sound FX', () => {
expect(isPlaceableType('bus')).toBe(false);
const f = withBuses('Music');
const bus = f.project.assets[0]!;
expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '', into: '' } });
expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx', 'into']);
expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '' } });
expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx']);
expect(roundTrip(f.project)).toEqual(f.project);
});
it('names the master as the empty pick of Into and of the Bus of a sound, an instrument, a pattern and a song', () => {
it('leaves where a sound, an instrument, a pattern, a song and a bus go to the routing (#351)', () => {
const f = withBuses('Music');
const into = schemaForAsset(f.project, f.project.assets[0]!).find((def) => def.key === BUS_INTO_KEY);
expect(into).toMatchObject({ kind: 'asset', assetTypes: ['bus'], none: 'Master' });
for (const type of ['sound', 'instrument', 'pattern', 'song'] as const) {
const command = new CreateAssetCommand(type, type);
f.stack.execute(command);
expect(schemaForAsset(f.project, command.asset).find((def) => def.key === BUS_KEY)).toMatchObject({
none: 'Master',
assetTypes: ['bus'],
});
expect(schemaForAsset(f.project, command.asset).map((def) => def.key)).not.toContain('bus');
}
});
it('offers as Into neither itself nor a bus that already goes into it', () => {
const f = withBuses('Master music', 'Music', 'Drums', 'SFX');
const [top, music, drums, sfx] = f.ids as [string, string, string, string];
f.into(music, top);
f.into(drums, music);
const rows = schemaForAsset(
f.project,
f.project.assets.find((a) => a.id === top)!,
);
expect(rows.find((d) => d.key === BUS_INTO_KEY)).toMatchObject({ intoFor: top });
expect(busesInto(f.project, top)).toEqual([top, music, drums]);
expect(busesInto(f.project, drums)).toEqual([drums]);
expect(busesInto(f.project, sfx)).toEqual([sfx]);
});
it('sends a bus that would come round to itself into the master, whatever the file says', () => {
const f = withBuses('A', 'B', 'C');
const [a, b, c] = f.ids as [string, string, string];
f.into(a, b);
f.into(b, c);
f.into(c, b);
// B and C go round each other: both go into the master. A goes into B, which is no loop.
expect([busInto(f.project, a), busInto(f.project, b), busInto(f.project, c)]).toEqual([b, '', '']);
f.into(a, a);
expect(busInto(f.project, a)).toBe('');
f.into(a, 'gone');
expect(busInto(f.project, a)).toBe('');
expect(schemaForAsset(f.project, f.project.assets[0]!).map((def) => def.key)).not.toContain('into');
});
});
@@ -167,15 +134,19 @@ describe('the mix (#325)', () => {
expect(sameMix(before, mixOf(f.project))).toBe(false);
});
it('plays on the bus a node picks, else the one its asset names, else the master', () => {
it('plays on the bus a node picks, else the one the routing sends its asset to, else the master', () => {
const f = withBuses('Music', 'SFX');
const [music, sfx] = f.ids as [string, string];
expect(playedBus(f.project, '', { bus: music })).toBe(`mix:${music}`);
expect(playedBus(f.project, sfx, { bus: music })).toBe(`mix:${sfx}`);
expect(playedBus(f.project, MASTER_BUS, { bus: music })).toBe(MASTER_MIX);
expect(playedBus(f.project, '', {})).toBe(MASTER_MIX);
// A bus that was deleted leaves it on the master.
expect(playedBus(f.project, '', { bus: 'gone' })).toBe(MASTER_MIX);
const sound = new CreateSoundCommand('Jump', FILE);
f.stack.execute(sound);
const id = sound.asset.id;
expect(playedBus(f.project, '', id)).toBe(MASTER_MIX);
f.into(id, music);
expect(playedBus(f.project, '', id)).toBe(`mix:${music}`);
expect(playedBus(f.project, sfx, id)).toBe(`mix:${sfx}`);
expect(playedBus(f.project, MASTER_BUS, id)).toBe(MASTER_MIX);
// A pick of a bus that was deleted leaves it on the master.
expect(playedBus(f.project, 'gone', id)).toBe(MASTER_MIX);
});
it('reaches the master for an empty pick, and no bus for one that is not there', () => {
@@ -219,13 +190,13 @@ function runOnStart(type: string, params: Record<string, string | number | boole
}
describe('buses in a running game (#325)', () => {
it('plays a sound on the bus its asset names, or on the one Play sound picks', () => {
it('plays a sound on the bus the routing sends it to, or on the one Play sound picks', () => {
const f = makeSpriteScene();
const bus = new CreateAssetCommand('bus', 'SFX');
const sound = new CreateSoundCommand('Jump', FILE);
f.stack.execute(bus);
f.stack.execute(sound);
f.stack.execute(new SetAssetPropertyCommand(sound.asset.id, BUS_KEY, bus.asset.id));
routingOf(f).only(sound.asset.id, bus.asset.id);
runOnStart('PlaySound', { sound: sound.asset.id, volume: 1 }, f);
const asset = busSink();
startWorld(f, { behaviors: true, audio: asset }).frame();
+4 -12
View File
@@ -71,8 +71,7 @@ describe('an embedded asset (#355)', () => {
it('hides the rows that do nothing in a song, only while embedded', () => {
const { f, takeId, blipId } = setup();
const take = findAsset(f.project, takeId)!;
expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
true,
expect(['loop', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
true,
false,
false,
@@ -308,18 +307,11 @@ describe('a pattern made in a song (#355)', () => {
]);
});
it('hides only its bus, not its clock, length, instrument or sound FX (#363)', () => {
it('hides none of its rows: not its clock, length, instrument or sound FX (#363)', () => {
const { make } = withPatterns();
const own = make();
const keys = ['bus', 'tempo', 'sign', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([
true,
false,
false,
false,
false,
false,
]);
const keys = ['tempo', 'sign', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([false, false, false, false, false]);
});
it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => {
+5 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isSongRoutingDef } from '../../src/core/music/routingNodes';
import { isMasterRoutingDef, isSongRoutingDef } from '../../src/core/music/routingNodes';
import { requireNodeDef } from '../../src/core/commands/require';
import { CommandError } from '../../src/core/commands/Command';
import {
@@ -82,9 +82,11 @@ describe('node registry', () => {
else if (def.category === 'state') expect(def.run).toBeUndefined();
// A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a
// stage of a voice, an instrument's own node to part of a note and a node of a song's routing
// to its buses (#350): none is ever run.
// (#350) or of the master routing (#351) to buses: none is ever run.
else if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound')
expect('emit' in def || 'instrument' in def || isSongRoutingDef(def)).toBe(true);
expect('emit' in def || 'instrument' in def || isSongRoutingDef(def) || isMasterRoutingDef(def)).toBe(
true,
);
// The end of a block runs as part of the node that opens it (`Find` … `Match`).
else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined();
// Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`).
+1
View File
@@ -155,6 +155,7 @@ describe('CommandStack', () => {
renderLayers: ['Default'],
masterVolume: 1,
masterFx: '',
routing: { nodes: [], edges: [] },
symbolScale: 1,
playFrom: 'start' as const,
whilePlaying: 'live' as const,
+265
View File
@@ -0,0 +1,265 @@
/**
* The master routing (#351): a node per sound, instrument, pattern, song and bus and the master,
* following the assets through every edit and its undo, and the mix the links make.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
DeleteAssetCommand,
RenameAssetCommand,
} from '../../src/core/commands/assetCommands';
import type { Command } from '../../src/core/commands/Command';
import { ConnectCommand, DisconnectCommand } from '../../src/core/commands/graphCommands';
import { KeepRoutingCommand } from '../../src/core/commands/routingCommands';
import { SetSendCommand } from '../../src/core/commands/songCommands';
import type { Graph } from '../../src/core/graph/Graph';
import { ROUTING_ID } from '../../src/core/model/Project';
import { MASTER_MIX, mixBusName, mixOf, playedRoute, routeOf, sameMix } from '../../src/core/music/mixer';
import { heldRouting, masterRoutingProblems, settleRouting } from '../../src/core/music/projectRouting';
import { ROUTED_ASSET, ROUTED_MASTER } from '../../src/core/music/routingNodes';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'b2'.repeat(32)}.wav`;
/** A project edited as the editor edits it: every command keeps the routing in step (#351). */
function edited() {
const f = makeProject();
const run = (command: Command) => f.stack.execute(new KeepRoutingCommand(command));
const create = (type: 'bus' | 'instrument' | 'pattern' | 'song' | 'scene', name: string) => {
const command = new CreateAssetCommand(type, name);
run(command);
return command.asset.id;
};
const sound = (name: string) => {
const command = new CreateSoundCommand(name, FILE);
run(command);
return command.asset.id;
};
const link = (from: string, to: string) => {
const command = new ConnectCommand(ROUTING_ID, { node: from, port: 'out' }, { node: to, port: 'in' });
run(command);
return command.edge.id;
};
const linksOf = (id: string) =>
f.project.routing.edges.filter((e) => e.from.node === id).map((e) => e.to.node);
return { ...f, run, create, sound, link, linksOf };
}
const edge = (from: string, to: string, extra: object = {}) => ({
id: `${from}>${to}`,
from: { node: from, port: 'out' },
to: { node: to, port: 'in' },
...extra,
});
describe('the nodes of the master routing (#351)', () => {
it('come with each sound, instrument, pattern, song and bus, linked to the master', () => {
const f = edited();
const ids = [f.sound('Kick'), f.create('instrument', 'Lead'), f.create('pattern', 'Riff')];
ids.push(f.create('song', 'Theme'), f.create('bus', 'Reverb'), f.sound('Snare'));
f.create('scene', 'Level');
const nodes = f.project.routing.nodes;
expect(nodes.map((n) => n.id).sort()).toEqual([...ids, MASTER_BUS].sort());
expect(nodes.find((n) => n.id === MASTER_BUS)?.type).toBe(ROUTED_MASTER);
for (const id of ids) expect(f.linksOf(id)).toEqual([MASTER_BUS]);
const at = (id: string) => nodes.find((n) => n.id === id)!;
expect(at(ids[0]!).type).toBe(ROUTED_ASSET);
expect(at(ids[0]!).params).toEqual({ name: 'Kick', kind: 'sound' });
// What plays below each other in the first column, the buses in the second, the master right of
// them a row above all of it: no link into it runs behind a bus.
expect(ids.map((id) => [at(id).x, at(id).y])).toEqual([
[0, 0],
[0, 80],
[0, 160],
[0, 240],
[320, 0],
[0, 320],
]);
expect([at(MASTER_BUS).x, at(MASTER_BUS).y]).toEqual([640, -80]);
});
it('come in by type as a project from before the routing is opened', () => {
const f = makeProject();
for (const command of [
new CreateAssetCommand('bus', 'Reverb'),
new CreateAssetCommand('song', 'Theme'),
new CreateSoundCommand('Kick', FILE),
new CreateAssetCommand('instrument', 'Lead'),
])
f.stack.execute(command);
settleRouting(f.project);
const shown = f.project.routing.nodes.map((n) => [n.params.name ?? 'Master', n.x, n.y]);
expect(shown).toEqual([
['Kick', 0, 0],
['Lead', 0, 80],
['Theme', 0, 160],
['Reverb', 320, 0],
['Master', 640, -80],
]);
});
it('follow a rename, and go with their asset and its links, all of it back on undo', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const onMaster = f.project.routing.edges.find((e) => e.from.node === kick)!;
f.link(kick, reverb);
f.run(new DisconnectCommand(ROUTING_ID, onMaster.id));
expect(f.linksOf(kick)).toEqual([reverb]);
f.run(new RenameAssetCommand(kick, 'Big kick'));
expect(f.project.routing.nodes.find((n) => n.id === kick)?.params.name).toBe('Big kick');
f.stack.undo();
expect(f.project.routing.nodes.find((n) => n.id === kick)?.params.name).toBe('Kick');
const before = JSON.stringify(f.project.routing);
f.run(new DeleteAssetCommand(reverb));
expect(f.project.routing.nodes.some((n) => n.id === reverb)).toBe(false);
// The kick went only into the bus, and goes to the master now: deleting a bus silences nothing.
expect(f.linksOf(kick)).toEqual([MASTER_BUS]);
f.stack.undo();
expect(JSON.stringify(f.project.routing)).toBe(before);
f.stack.redo();
expect(f.project.routing.nodes.some((n) => n.id === reverb)).toBe(false);
expect(f.linksOf(kick)).toEqual([MASTER_BUS]);
});
it('leave a node without a link alone when a bus goes that it was not sent to', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const send = f.project.routing.edges.find((e) => e.from.node === kick)!;
f.stack.execute(
new KeepRoutingCommand({
label: 'Unlink',
execute: (p) => void p.routing.edges.splice(p.routing.edges.indexOf(send), 1),
undo: () => {},
}),
);
f.run(new DeleteAssetCommand(reverb));
expect(f.linksOf(kick)).toEqual([]);
expect(masterRoutingProblems(f.project.routing)[kick]).toMatch(/^Goes nowhere/);
});
it('are not added as a project is read, and a settled routing reads back as it was', () => {
const f = makeProject();
f.stack.execute(new CreateSoundCommand('Kick', FILE));
expect(roundTrip(f.project).routing).toEqual({ nodes: [], edges: [] });
const g = edited();
g.link(g.sound('Kick'), g.create('bus', 'Reverb'));
g.create('pattern', 'Riff');
expect(roundTrip(g.project)).toEqual(g.project);
});
it('keep no link that would loop, say again what another says, or go into what is no bus', () => {
const assets = [
{ id: 'a', type: 'bus', name: 'A', props: {} },
{ id: 'b', type: 'bus', name: 'B', props: {} },
{ id: 's', type: 'sound', name: 'S', props: {} },
{ id: 'p', type: 'pattern', name: 'P', props: {} },
] as never[];
const node = (id: string, kind: string) => ({
id,
type: ROUTED_ASSET,
x: 0,
y: 0,
params: { name: id, kind },
});
const graph: Graph = {
nodes: [node('a', 'bus'), node('b', 'bus'), node('s', 'sound'), node('p', 'pattern')],
edges: [
edge('a', 'b'),
edge('b', 'a'),
edge('s', 'a', { send: { volume: -6, tap: 'preFx' } }),
{ ...edge('s', 'a'), id: 'again' },
edge('p', 's'),
edge('b', MASTER_BUS, { send: { volume: 3, tap: 'preFx' } }),
],
};
const held = heldRouting(graph, assets, false);
expect(held.edges.map((e) => e.id)).toEqual(['a>b', 's>a', 'b>#master', expect.any(String)]);
// A sound sends after its volume and FX; a bus may tap before them.
expect(held.edges.find((e) => e.id === 's>a')?.send).toEqual({ volume: -6, tap: 'post' });
expect(held.edges.find((e) => e.id === 'b>#master')?.send).toEqual({ volume: 3, tap: 'preFx' });
// The pattern's one link went into a sound: it goes to the master instead.
expect(held.edges.at(-1)).toMatchObject({ from: { node: 'p' }, to: { node: MASTER_BUS } });
});
it('take a send of a sound only after its volume, and of a bus from where it is asked', () => {
const f = edited();
const kick = f.sound('Kick');
const reverb = f.create('bus', 'Reverb');
const toBus = f.link(kick, reverb);
const out = f.project.routing.edges.find((e) => e.from.node === reverb)!.id;
f.run(new SetSendCommand(ROUTING_ID, toBus, { volume: -12, tap: 'preFx' }));
f.run(new SetSendCommand(ROUTING_ID, out, { volume: 0, tap: 'postFx' }));
const sendOf = (id: string) => f.project.routing.edges.find((e) => e.id === id)?.send;
expect([sendOf(toBus), sendOf(out)]).toEqual([
{ volume: -12, tap: 'post' },
{ volume: 0, tap: 'postFx' },
]);
});
});
describe('the mix of the master routing (#351)', () => {
it('sends what has no node, or one link at 0 dB, straight into that bus', () => {
const f = makeProject();
const sound = new CreateSoundCommand('Kick', FILE);
f.stack.execute(sound);
expect(routeOf(f.project, sound.asset.id)).toBe(MASTER_MIX);
const g = edited();
const kick = g.sound('Kick');
const reverb = g.create('bus', 'Reverb');
expect(routeOf(g.project, kick)).toBe(MASTER_MIX);
g.link(kick, reverb);
g.run({
label: 'Off the master',
execute: (p) =>
void (p.routing.edges = p.routing.edges.filter(
(e) => !(e.from.node === kick && e.to.node === MASTER_BUS),
)),
undo: () => {},
});
expect(routeOf(g.project, kick)).toBe(mixBusName(reverb));
});
it('gives a sound with more than that a bus of its own with its sends, and plays a song along them', () => {
const f = edited();
const kick = f.sound('Kick');
const song = f.create('song', 'Theme');
const reverb = f.create('bus', 'Reverb');
const send = f.link(kick, reverb);
f.run(new SetSendCommand(ROUTING_ID, send, { volume: -6, tap: 'post' }));
const before = mixOf(f.project);
const own = before.find((bus) => bus.name === mixBusName(kick));
expect(own).toMatchObject({ volume: 1, fx: null });
expect(own?.sends?.map((s) => [s.to, s.tap, Math.round(s.gain * 1000) / 1000])).toEqual([
[MASTER_MIX, 'post', 1],
[mixBusName(reverb), 'post', 0.501],
]);
expect(before.find((bus) => bus.name === mixBusName(reverb))).toMatchObject({ into: MASTER_MIX });
expect(before.some((bus) => bus.name === mixBusName(song))).toBe(false);
f.run(new SetSendCommand(ROUTING_ID, send, { volume: -3, tap: 'post' }));
expect(sameMix(before, mixOf(f.project))).toBe(false);
const toReverb = f.link(song, reverb);
f.run(new SetSendCommand(ROUTING_ID, toReverb, { volume: 0, tap: 'preFx' }));
const route = playedRoute(f.project, '', song);
expect(typeof route !== 'string' && route.map((s) => [s.to, s.tap])).toEqual([
[MASTER_MIX, 'post'],
[mixBusName(reverb), 'preFx'],
]);
// A bus the node picks takes it there alone.
expect(playedRoute(f.project, reverb, song)).toBe(mixBusName(reverb));
expect(playedRoute(f.project, MASTER_BUS, song)).toBe(MASTER_MIX);
});
it('plays an asset with no link at all on nothing', () => {
const f = edited();
const kick = f.sound('Kick');
f.run({ label: 'Unlink', execute: (p) => void (p.routing.edges = []), undo: () => {} });
expect(routeOf(f.project, kick)).toEqual([]);
expect(mixOf(f.project).find((bus) => bus.name === mixBusName(kick))?.sends).toEqual([]);
});
});
-1
View File
@@ -56,7 +56,6 @@ describe('pattern asset (#333)', () => {
'sign',
'tempo',
'fx',
'bus',
]);
});
+1 -7
View File
@@ -71,13 +71,7 @@ describe('the song asset (#334)', () => {
const f = makeSong(1);
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual([
'tempo',
'sign',
'grid',
'fx',
'bus',
]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']);
expect(songGrid(f.song().props)).toBe(BAR);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
expect(songGrid(f.song().props)).toBe(160);
+1 -2
View File
@@ -40,7 +40,6 @@ import {
VOLUME_KEY,
volumeOf,
} from '../../src/core/properties/sounds';
import { BUS_KEY } from '../../src/core/properties/buses';
import { deserializeProject } from '../../src/core/store/serialize';
import type { AudioSink } from '../../src/runtime/systems/Audio';
import {
@@ -131,7 +130,7 @@ describe('the sound asset', () => {
expect(volumeOf(asset.props)).toBe(1);
expect(loopsOf(asset.props)).toBe(false);
// The cut belongs to a clip of the sound (#357), the sound is the whole file.
expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx', BUS_KEY]);
expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx']);
expect(CLIP_ROWS.map((def) => def.key)).toEqual([
START_KEY,
END_KEY,
+1
View File
@@ -189,6 +189,7 @@ describe('a selection of links', () => {
type: 'number',
name: '',
send: null,
tapped: false,
});
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
+26 -2
View File
@@ -1,6 +1,6 @@
import { CreateAssetCommand } from '../src/core/commands/assetCommands';
import { CommandStack } from '../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands';
import { AddNodeCommand, ConnectCommand, DisconnectCommand } from '../src/core/commands/graphCommands';
import { AddPortsCommand } from '../src/core/commands/portCommands';
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
import { requireNodeDef } from '../src/core/commands/require';
@@ -29,7 +29,9 @@ import { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform';
import { resolvedHolder } from '../src/core/model/instance';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
import { createEmptyProject, findScene, type Project } from '../src/core/model/Project';
import { createEmptyProject, findScene, type Project, ROUTING_ID } from '../src/core/model/Project';
import { settleRouting } from '../src/core/music/projectRouting';
import { MASTER_BUS } from '../src/core/properties/buses';
import type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import type { AudioSink } from '../src/runtime/systems/Audio';
@@ -65,6 +67,28 @@ export function makeProject(): ProjectFixture {
return { project, stack: new CommandStack(project) };
}
/**
* The master routing of a fixture (#351), held to its assets before each change: `link` adds a link
* from an asset to a bus or the master, `only` makes that its one link.
*/
export function routingOf({ project, stack }: ProjectFixture) {
const link = (from: string, to = MASTER_BUS) => {
settleRouting(project);
const command = new ConnectCommand(ROUTING_ID, { node: from, port: 'out' }, { node: to, port: 'in' });
stack.execute(command);
return command.edge;
};
return {
link,
only(from: string, to = MASTER_BUS) {
settleRouting(project);
for (const edge of project.routing.edges.filter((e) => e.from.node === from))
stack.execute(new DisconnectCommand(ROUTING_ID, edge.id));
return link(from, to);
},
};
}
/** A project with a scene "Level" and a 100×100 sprite (named "Box" unless told otherwise), plus lookups into the scene. */
export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
const fixture = makeProject();
+44
View File
@@ -2295,6 +2295,50 @@ describe('the master and the buses of the project (#325)', () => {
expect(levels(library).map((level) => level.name)).toEqual(['mix', 'mix:a']);
});
it('sends a bus of the mix along the links of the master routing, and back into one bus (#351)', async () => {
const library = new SoundLibrary(false);
await play(library);
const sends = [
{ to: 'mix', gain: 1, tap: 'post' as const },
{ to: 'mix:a', gain: 0.5, tap: 'preFx' as const },
];
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, sends }]);
const b = library.busLevels().find((level) => level.name === 'mix:b');
expect(b?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([
['mix', 1, 'post'],
['mix:a', 0.5, 'preFx'],
]);
expect(b?.into).toBeNull();
await library.setMix(mix);
expect(levels(library).at(-1)).toEqual({ name: 'mix:b', gain: 0.25, into: 'mix:a' });
});
it('sends the master of a playing along the links of its pattern or song (#351)', async () => {
const library = new SoundLibrary(false);
await library.setMix(mix);
const id = library.playSequence(
'song:s',
{
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
sends: [
{ to: 'mix', gain: 1, tap: 'post' },
{ to: 'mix:b', gain: 0.25, tap: 'preFx' },
],
strike: () => 0,
},
'',
);
const master = library.busLevels().find((level) => level.name === `play:${id}`);
expect(master?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([
['mix', 1, 'post'],
['mix:b', 0.25, 'preFx'],
]);
});
it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => {
const library = new SoundLibrary(false);
await library.setMix(mix);
+100 -31
View File
@@ -20,7 +20,7 @@ const DRONE_BYTES = 12044;
export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
const { select } = t;
const { select, drag } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
@@ -2262,55 +2262,123 @@ export async function sound(t) {
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
);
// 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the
// master's volume is a project setting, one undo step.
// 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
// one; the tab has a node for it and for the sound, each linked to the master. Links drawn
// there and one deleted send the sound into a bus and that bus into the other, as the Sound
// tab then plays it. A click on a node shows its asset, on the master the master's volume.
for (let i = 0; i < 2; i++) {
await clickText('+');
await pickAssetType(t, 'Bus');
await sleep(150);
}
const [busA, busB] = await assetsOfType('bus');
const intoMenu = async () => {
await select({ kind: 'asset', assetId: busA.id });
const at = await json("__gm.inspectorField('Into', true)");
await click(at.x, at.y);
const offered = await json('__gm.overlayTexts()');
await press('Escape');
return offered;
};
const offeredBefore = await intoMenu();
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`);
const offeredAfter = await intoMenu();
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(300);
const routing = () => json('__gm.routing.graph()');
const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() };
const labels = await json(
`[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`,
);
await select({ kind: 'asset', assetId: droneId });
const busRow = await json("__gm.inspectorField('Bus', true)");
await select({ kind: 'asset', assetId: busA.id });
const intoRow = await json("__gm.inspectorField('Into', true)");
check(
'"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it',
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') &&
JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) &&
JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']),
JSON.stringify({ busA, busB, offeredBefore, offeredAfter }),
'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows',
opened.shown &&
[droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) &&
[droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) &&
JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) &&
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) &&
busRow === null &&
intoRow === null,
JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }),
);
const portOf = (nodeId, key, isOutput) =>
json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const link = async (from, to) => {
const a = await portOf(from, 'out', true);
const b = await portOf(to, 'in', false);
await drag(a.x, a.y, b.x, b.y);
await sleep(200);
};
// The link is picked by the model: its line may run behind a node, as lines do.
const unlink = async (from, to) => {
const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to);
await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] });
await press('Delete');
await sleep(150);
};
await link(droneId, busB.id);
await link(busB.id, busA.id);
await unlink(droneId, '#master');
await unlink(busB.id, '#master');
await shot('h3-routing');
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ${JSON.stringify(busB.id)};`);
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10);
await sleep(300);
const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus;
const peak = await waitFor(
() => json(`__gm.routing.peaks([${JSON.stringify(`mix:${busB.id}`)}])`),
(peaks) => Math.max(...(peaks[`mix:${busB.id}`] ?? [0])) > 0.01,
);
await click(tabPlay.x, tabPlay.y);
const levelOf = (name) => buses.find((bus) => bus.name === name);
check(
'the Sound tab plays the sound on its bus, into the bus that one names, on into the master',
heardOn === `mix:${busB.id}` &&
'linked in the routing, the Sound tab plays the sound on its bus, into the bus that one goes to, on into the master',
links(await routing())
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.id))
.join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` &&
heardOn === `mix:${busB.id}` &&
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null,
JSON.stringify({ heardOn, buses }),
levelOf('mix')?.into === null &&
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) > 0.01,
JSON.stringify({ links: links(await routing()), heardOn, buses, peak }),
);
await t.clickToolbar('menu');
await clickText('Master routing');
await sleep(200);
let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`);
await click(at.x, at.y);
await sleep(200);
const busShown = await json("__gm.inspectorField('Volume', true)");
at = await json("__gm.routing.nodeAt('#master')");
await click(at.x, at.y);
await sleep(200);
await shot('h3-master');
const masterField = await json("__gm.inspectorField('Volume', true)");
await click(masterField.x, masterField.y);
await press('a', { ctrl: true });
await t.typeText('0.6');
await press('Enter');
await sleep(150);
const masterSet = await json('__gm.state.project.masterVolume');
await t.clickToolbar('undo');
await sleep(150);
const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') };
at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`);
await click(at.x, at.y);
await click(at.x, at.y);
await sleep(300);
check(
'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound',
busShown !== null &&
masterShown.set === 0.6 &&
masterShown.undone === 1 &&
(await json('__gm.soundTab.current()')) === droneId &&
(await json('__gm.routing.shown()')) === false,
JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }),
);
await t.clickToolbar('menu');
@@ -2337,10 +2405,11 @@ export async function sound(t) {
mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
JSON.stringify({ masterAt, mastered, undone }),
);
// The routing starts again from the assets with the next edit: everything on the master.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = '';
p.assets = p.assets.filter((a) => a.type !== 'bus');`);
p.assets = p.assets.filter((a) => a.type !== 'bus');
p.routing = { nodes: [], edges: [] };`);
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`