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 { EdgeSend, Graph } from '../graph/Graph';
import type { Asset, SongAsset } from '../model/Asset'; 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 { settleEmbedded } from '../music/embedded';
import { routedKind, sendsTapped } from '../music/projectRouting';
import { heldSend, heldSongGraph } from '../music/routing'; import { heldSend, heldSongGraph } from '../music/routing';
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song'; import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
import { heldLoop, type LoopRange } from '../music/sequence'; import { heldLoop, type LoopRange } from '../music/sequence';
import { type Removed, removeWhere, restoreAt } from '../util/list'; import { type Removed, removeWhere, restoreAt } from '../util/list';
import type { Command } from './Command'; import { type Command, CommandError } from './Command';
import { requireEdge, requireSong } from './require'; import { requireEdge, requireSong } from './require';
export interface SongParts { 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 { export class SetSendCommand implements Command {
readonly label = 'Set send'; readonly label = 'Set send';
private previous: EdgeSend | undefined; private previous: EdgeSend | undefined;
@@ -87,10 +91,14 @@ export class SetSendCommand implements Command {
) {} ) {}
execute(project: Project): void { 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 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; this.previous = edge.send;
const held = heldSend(this.send); const held = heldSend(tapped ? this.send : { ...this.send, tap: 'post' });
if (held) edge.send = held; if (held) edge.send = held;
else delete edge.send; else delete edge.send;
} }
+24 -1
View File
@@ -6,6 +6,7 @@ import {
isSceneAsset, isSceneAsset,
type SceneAsset, type SceneAsset,
} from './Asset'; } from './Asset';
import { createEmptyGraph, type Graph } from '../graph/Graph';
import { DEFAULT_LAYER_NAME } from './collisionLayers'; import { DEFAULT_LAYER_NAME } from './collisionLayers';
import { DEFAULT_MASTER_VOLUME } from './master'; import { DEFAULT_MASTER_VOLUME } from './master';
import { import {
@@ -60,6 +61,11 @@ export interface Project {
*/ */
masterVolume: number; masterVolume: number;
masterFx: string; 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 * 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. * 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], renderLayers: [DEFAULT_LAYER_NAME],
masterVolume: DEFAULT_MASTER_VOLUME, masterVolume: DEFAULT_MASTER_VOLUME,
masterFx: '', masterFx: '',
routing: createEmptyGraph(),
symbolScale: DEFAULT_SYMBOL_SCALE, symbolScale: DEFAULT_SYMBOL_SCALE,
playFrom: DEFAULT_PLAY_FROM, playFrom: DEFAULT_PLAY_FROM,
whilePlaying: DEFAULT_WHILE_PLAYING, whilePlaying: DEFAULT_WHILE_PLAYING,
@@ -122,7 +129,23 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef
return asset && isSceneAsset(asset) ? asset : undefined; 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); const asset = findAsset(project, assetId);
return asset && isGraphAsset(asset) ? asset : undefined; 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']; 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 * The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern
* song's. A pattern keeps its tempo and meter (#363), as one of the project does. * keeps its tempo and meter (#363), as one of the project does.
*/ */
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = { const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
sound: ['loop', 'bus'], sound: ['loop'],
pattern: ['bus'],
}; };
export function isEmbeddable(type: AssetType): boolean { 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 { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties'; import { BUS_FX_KEY, busVolumeOf, MASTER_BUS } from '../properties/buses';
import { BUS_FX_KEY, busInto, busOf, busVolumeOf, MASTER_BUS } from '../properties/buses';
import { coerceAs } from '../properties/kinds'; import { coerceAs } from '../properties/kinds';
import type { FxVoice } from '../soundfx/nodes'; import type { FxVoice } from '../soundfx/nodes';
import { fxVoiceOf } from './needs'; import { fxVoiceOf } from './needs';
import { isRouted } from './projectRouting';
import { sendOf } from './routing';
import { dbToGain } from './song';
import { DEFAULT_TEMPO } from './time'; import { DEFAULT_TEMPO } from './time';
/** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */ /** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */
export const MASTER_MIX = 'mix'; 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. */ /** One bus of the mix as the sound library sets it up: its level, its sound FX and where it goes. */
export interface MixBus { export interface MixBus {
name: string; name: string;
volume: number; volume: number;
/** The bus it goes into; null for the master, which is heard. */ /** The bus it goes into; null for the master, which is heard. */
into: string | null; into: string | null;
/** Where it goes instead, along the links of the routing (#351); `into` then says nothing. */
sends?: readonly MixSend[];
fx: FxVoice | null; 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 * Where an asset goes by the master routing (#351). One the routing has no node for goes into the
* that is missing or would make a loop. A sound FX that does not compile is none. * 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[] { export function mixOf(project: Project): MixBus[] {
const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO); const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO);
const buses = project.assets.flatMap((asset) => const routed = (route: MixRoute) =>
asset.type === 'bus' typeof route === 'string' ? { into: route } : { into: MASTER_MIX, sends: route };
? [ const buses = project.assets.flatMap((asset): MixBus[] => {
{ if (asset.type === 'bus')
name: mixBusName(asset.id), return [
volume: busVolumeOf(asset.props), {
into: mixBusName(busInto(project, asset.id)), name: mixBusName(asset.id),
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')), 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 [ return [
{ name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) }, { name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) },
...buses, ...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. */ /** 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 { export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
return ( return (
@@ -57,6 +107,7 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
bus.name === other.name && bus.name === other.name &&
bus.volume === other.volume && bus.volume === other.volume &&
bus.into === other.into && bus.into === other.into &&
sameSends(bus.sends, other.sends) &&
bus.fx?.plan === other.fx?.plan && bus.fx?.plan === other.fx?.plan &&
bus.fx?.tempo === other.fx?.tempo 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. * 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 { export function playedBus(project: Project, picked: string, assetId: string): string {
const id = picked !== '' ? picked : busOf(assetProps); const route = playedRoute(project, picked, assetId);
if (id === MASTER_BUS || id === '') return MASTER_MIX; return typeof route === 'string' ? route : mixBusName(assetId);
return findAssetOf(project, id, 'bus') ? mixBusName(id) : MASTER_MIX; }
/** 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. */ /** 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 { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes'; import type { FxPlan, FxVoice } from '../soundfx/nodes';
import type { MixSend } from './mixer';
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
import type { LoopRange, Sequence } from './sequence'; import type { LoopRange, Sequence } from './sequence';
@@ -47,6 +48,8 @@ export interface SequencePlay {
fx?: FxVoice; fx?: FxVoice;
/** The bus of the mix the whole playing is heard on (#325); the master where left out. */ /** The bus of the mix the whole playing is heard on (#325); the master where left out. */
bus?: string; 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. */ /** Starts one note as the player was asked to, and gives back the number of its voice. */
strike(note: ScheduledNote): number; strike(note: ScheduledNote): number;
strikeClip?(clip: ScheduledClip): 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 { export function isSongRoutingDef(def: NodeDef): boolean {
return def.type === SONG_TRACK || def.type === SONG_OUTPUT; 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'; } from '../music/song';
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time'; import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
import { ASSET_FX_KEY } from './assetFx'; 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'; 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 * 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, * `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 * `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 * "from the asset" (#352); `uploadedOnly` keeps drawn pictures out
* the Into of, which leaves out that bus and every one going into it when the picker opens (#325);
* `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers * 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. * 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; none?: string;
master?: boolean; master?: boolean;
noFx?: boolean; noFx?: boolean;
intoFor?: string;
} }
: K extends 'texture' : K extends 'texture'
? { uploadedOnly?: boolean } ? { uploadedOnly?: boolean }
@@ -485,16 +482,6 @@ const SLICE_SCALE_ROW: PropertyDef = {
step: 0.1, 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). */ /** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
const FX_ROW: PropertyDef = { const FX_ROW: PropertyDef = {
key: ASSET_FX_KEY, 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_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 }, { key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
FX_ROW, FX_ROW,
BUS_ROW,
], ],
// The effects are the asset's graph (see SoundFxAsset), not a property. // The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [], soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property. // 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 // 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). // tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
pattern: [ pattern: [
@@ -744,7 +730,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
step: 1, step: 1,
}, },
FX_ROW, FX_ROW,
BUS_ROW,
], ],
// The tracks, items and loop are intrinsic data on the asset (see SongAsset). // The tracks, items and loop are intrinsic data on the asset (see SongAsset).
song: [ song: [
@@ -766,13 +751,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
options: SONG_GRID_OPTIONS, options: SONG_GRID_OPTIONS,
}, },
FX_ROW, 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: [ bus: [
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 }, { 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'] }, { 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 // 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`. // 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. // 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 === 'variables' || asset.type === 'struct') return asset.variables;
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; 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. // 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') if (asset.type === 'particles')
return sourceRows( return sourceRows(
+2 -43
View File
@@ -1,59 +1,18 @@
import { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties'; import type { PropertyBag } from '../model/Properties';
import { coerceAs } from './kinds'; 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. */ /** How loud a bus is, 0–1. */
export const BUS_VOLUME_KEY = 'volume'; export const BUS_VOLUME_KEY = 'volume';
/** The sound FX all of a bus goes through, before its volume. */ /** The sound FX all of a bus goes through, before its volume. */
export const BUS_FX_KEY = 'fx'; 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 * 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 const MASTER_BUS = '#master';
export function busOf(bag: PropertyBag): string {
return coerceAs(bag[BUS_KEY], 'asset', '');
}
export function busVolumeOf(bag: PropertyBag): number { export function busVolumeOf(bag: PropertyBag): number {
return Math.max(0, Math.min(1, coerceAs(bag[BUS_VOLUME_KEY], 'number', 1))); 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 { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song'; import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing'; 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 { heldLoop, type LoopRange } from '../music/sequence';
import { isKeyInterpolation, normalizeKeys } from '../model/animation'; import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type { import type {
@@ -340,6 +341,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
renderLayers: renderLayersOf(r), renderLayers: renderLayersOf(r),
masterVolume: masterVolumeOf(r), masterVolume: masterVolumeOf(r),
masterFx: masterFxOf(r), masterFx: masterFxOf(r),
routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false),
symbolScale: symbolScaleOf(r), symbolScale: symbolScaleOf(r),
playFrom: playFromOf(r), playFrom: playFromOf(r),
whilePlaying: whilePlayingOf(r), whilePlaying: whilePlayingOf(r),
+27 -12
View File
@@ -1,6 +1,6 @@
import type { Application, Ticker } from 'pixi.js'; import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset'; 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 { assetTitle } from '../core/music/embedded';
import { mixOf } from '../core/music/mixer'; import { mixOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore'; import type { Role } from '../core/store/AuthStore';
@@ -94,6 +94,7 @@ import type { PluginManager } from './plugins/loader';
import { PluginsPane } from './plugins/PluginsPane'; import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane'; import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane'; import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor';
export type CentreTab = export type CentreTab =
| 'scene' | 'scene'
@@ -115,6 +116,7 @@ export type CentreTab =
| 'tile' | 'tile'
| 'container' | 'container'
| 'variables' | 'variables'
| 'routing'
| 'management'; | 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */ /** 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; export type TabId = CentreTab | PluginTabId;
/** /**
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its * 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 * `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
* opens and closes like one. * (#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. */ /** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount { export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */ /** 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]; type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */ /** 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; const FIXED_TABS = ['scene', 'game'] as const;
@@ -168,6 +170,7 @@ const PANE_ORDER = [
'tile', 'tile',
'container', 'container',
'variables', 'variables',
'routing',
'management', 'management',
] as const; ] as const;
@@ -192,6 +195,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
tile: 'Tile', tile: 'Tile',
container: 'Container', container: 'Container',
variables: 'Variables', variables: 'Variables',
routing: 'Master routing',
management: 'User management', management: 'User management',
}; };
@@ -202,7 +206,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
}; };
function isDocument(tab: StripTab): tab is DocumentId { 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; const MIN_LEFT_WIDTH = 160;
@@ -245,6 +249,7 @@ export class Editor implements PlayHost {
readonly tileEditor: TileEditor; readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor; readonly containerEditor: ContainerEditor;
readonly variablesEditor: VariablesEditor; readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
/** What each open Variables tab shows, by its document. */ /** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>(); private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */ /** 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.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state); this.containerEditor = new ContainerEditor(state);
this.variablesEditor = new VariablesEditor(state); this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.hierarchy = new HierarchyPanel(state); this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state); this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state); this.tilemapPanel = new TilemapPanel(state);
@@ -383,7 +389,10 @@ export class Editor implements PlayHost {
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane); this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, { 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()), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(), togglePause: () => this.play.togglePause(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
@@ -453,6 +462,7 @@ export class Editor implements PlayHost {
game: this.gamePane, game: this.gamePane,
...this.assetEditors, ...this.assetEditors,
variables: this.variablesEditor, variables: this.variablesEditor,
routing: this.routingEditor,
management: this.managementPane, management: this.managementPane,
}; };
// Following a call goes on in the same tab: the tab changes its document, not its place. // 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. // creating it) is for – see the `open` event below.
state.on('selection', () => { state.on('selection', () => {
const s = state.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}`); this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container') if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`); this.openDocument(`asset:${s.holderId}`);
@@ -791,6 +802,7 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors) if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc); this.valueTargets.delete(doc);
if (doc === 'routing') this.routingEditor.close();
if (!wasShowing) return; if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active); if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene'); else this.showTab('scene');
@@ -807,8 +819,8 @@ export class Editor implements PlayHost {
if (doc) this.openDocument(doc); if (doc) this.openDocument(doc);
return; return;
} }
if (tab === 'management') { if (tab === 'management' || tab === 'routing') {
this.openDocument('management'); this.openDocument(tab);
return; return;
} }
if (tab in this.assetEditors) { if (tab in this.assetEditors) {
@@ -856,7 +868,7 @@ export class Editor implements PlayHost {
const target = this.valueTargets.get(doc); const target = this.valueTargets.get(doc);
return target && valuesTitle(this.state, target) !== null ? 'variables' : null; 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 type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined; const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null; return kind && kind !== 'scene' ? kind : null;
@@ -870,12 +882,14 @@ export class Editor implements PlayHost {
if (kind === 'variables' && target) this.variablesEditor.show(target); if (kind === 'variables' && target) this.variablesEditor.show(target);
// Read on every showing: an account may have been changed from another tab in the meantime. // 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 === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open();
this.display(kind, doc); this.display(kind, doc);
} }
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ /** 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 { private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing;
const target = this.valueTargets.get(doc); const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length)); 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. */ /** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string { private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG; 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); const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables; if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; 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); const kind = this.kindOf(doc);
if (!this.tabs.has(doc) && kind) if (!this.tabs.has(doc) && kind)
this.tabs.addTab(doc, this.labelOf(doc), base + index, { 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), icon: this.iconOf(doc),
onClose: () => this.closeDocument(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. * 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[] = [ const items: MenuEntry[] = [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) }, { label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() }, { label: 'Open project…', onSelect: () => this.openProjectPicker() },
@@ -115,6 +116,7 @@ export class ProjectSession {
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() }, { label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() }, { label: 'Release…', onSelect: () => this.openRelease() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() },
...project,
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() }, { label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more, ...more,
]; ];
+9 -4
View File
@@ -13,12 +13,17 @@ import {
namesBehavior, namesBehavior,
SHADER_CALL, SHADER_CALL,
} from '../core/graph'; } from '../core/graph';
import type { GraphAsset } from '../core/model/Asset';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy'; import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance'; import { isLiveInstance } from '../core/model/instance';
import type { Vec2 } from '../core/model/Properties'; 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 { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries'; import { paletteEntries } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state'; 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. */ /** 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( return paletteEntries(project, asset).some(
(entry) => (entry) =>
entry.def.type === node.type && 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. */ /** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart( function fittingPart(
project: Project, project: Project,
asset: GraphAsset, asset: GraphHolder,
clip: GraphClip, clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } { ): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n)); 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 { curveHooks } from './debug/curveHooks';
import { soundHooks } from './debug/soundHooks'; import { soundHooks } from './debug/soundHooks';
import { patternHooks } from './debug/patternHooks'; import { patternHooks } from './debug/patternHooks';
import { routingHooks } from './debug/routingHooks';
import { songHooks } from './debug/songHooks'; import { songHooks } from './debug/songHooks';
import { documentHooks } from './debug/documentHooks'; import { documentHooks } from './debug/documentHooks';
import { graphHooks } from './debug/graphHooks'; import { graphHooks } from './debug/graphHooks';
@@ -50,6 +51,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
...curveHooks(context), ...curveHooks(context),
...patternHooks(context), ...patternHooks(context),
...songHooks(context), ...songHooks(context),
...routingHooks(context),
...soundHooks(context), ...soundHooks(context),
...renderHooks(context), ...renderHooks(context),
/** /**
+16 -5
View File
@@ -26,14 +26,16 @@ import {
soundFxParamsMismatch, soundFxParamsMismatch,
startStateOf, startStateOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary'; import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile'; import { soundFxBuildFor } from '../../core/soundfx/compile';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile'; import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing'; 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 { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween'; import { ViewTween } from '../viewTween';
import { BeadClock } from './beads'; 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) }; 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; 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 ? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: asset.type === 'song' : asset.type === 'song'
? routingProblems(asset.graph) ? routingProblems(asset.graph)
: null; : asset.type === 'routing'
? masterRoutingProblems(asset.graph)
: null;
for (const node of asset.graph.nodes) { for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup); const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue; 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 * 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. * "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'>(); const marks = new Map<string, 'held' | 'current'>();
if (asset.type !== 'statemachine') return marks; if (asset.type !== 'statemachine') return marks;
const selection = this.state.selection; const selection = this.state.selection;
@@ -665,6 +669,13 @@ export class GraphCanvas extends Widget {
} }
private openCalledBehavior(node: GraphNode): boolean { 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) { if (node.type === SHADER_CALL || node.type === INSTRUMENT_SOUND_FX) {
const shader = node.type === SHADER_CALL; const shader = node.type === SHADER_CALL;
const id = textOf(node.params[shader ? FUNCTION_PARAM : 'fx'] ?? ''); 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 { 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 { Box } from '../framing';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import type { NodeView } from './NodeView'; import type { NodeView } from './NodeView';
@@ -11,7 +11,7 @@ export interface GraphCanvasHost {
readonly state: EditorState; readonly state: EditorState;
/** The asset on the canvas; empty when there is none. */ /** The asset on the canvas; empty when there is none. */
assetId(): string; assetId(): string;
asset(): GraphAsset | null; asset(): GraphHolder | null;
views(): ReadonlyMap<string, NodeView>; views(): ReadonlyMap<string, NodeView>;
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */ /** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
topDownViews(): Iterable<NodeView>; topDownViews(): Iterable<NodeView>;
+5 -5
View File
@@ -21,11 +21,11 @@ import {
STATE_NAME_PARAM, STATE_NAME_PARAM,
terminalNode, terminalNode,
} from '../../core/graph'; } 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 { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project'; import type { GraphHolder, Project } from '../../core/model/Project';
import { import {
isParticlePropertyNode, isParticlePropertyNode,
PARTICLE_PROPERTY, 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 * 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. * 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') { if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) }; const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }]; return [{ id: State.type, hint: State.category, def: State, params }];
} }
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset); 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). // A routing holds nodes that come and go on their own: a song's tracks (#350), the assets (#351).
if (asset.type === 'song') return []; if (asset.type === 'song' || asset.type === 'routing') return [];
if (asset.type === 'soundfx') { if (asset.type === 'soundfx') {
const placed = new Set(asset.graph.nodes.map((n) => n.type)); const placed = new Set(asset.graph.nodes.map((n) => n.type));
return SOUND_FX_NODES.filter( return SOUND_FX_NODES.filter(
+24 -17
View File
@@ -17,7 +17,9 @@ import {
objectSignature, objectSignature,
refreshObjectSection, refreshObjectSection,
} from './objectSection'; } from './objectSection';
import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection';
import { buildSongSection, refreshSongSection, songSignature } from './songSection'; import { buildSongSection, refreshSongSection, songSignature } from './songSection';
import { ROUTING_ID } from '../../core/model/Project';
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
import { INLINE_LIMIT } from '../variables/target'; import { INLINE_LIMIT } from '../variables/target';
import { inlineListsIn } from '../variables/VariablesEditor'; 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 }), 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 === '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 === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId); 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; const s = this.state.selection;
if (!s) return ''; if (!s) return '';
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); 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 === '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 === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId); if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId);
@@ -176,23 +181,25 @@ export class Inspector extends Panel {
const ok = const ok =
s.kind === 'asset' s.kind === 'asset'
? refreshAssetSection(this.host, s.assetId) ? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node' : s.kind === 'node' && s.assetId === ROUTING_ID
? refreshNodeSection(this.host, s.assetId, s.nodeId) ? refreshRoutingNodeSection(this.host, s.nodeId)
: s.kind === 'frame' : s.kind === 'node'
? refreshFrameSection(this.host, s.assetId, s.frameId) ? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'clip' : s.kind === 'frame'
? refreshClipSection(this.host, s.assetId, s.clipId) ? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.kind === 'shape' : s.kind === 'clip'
? refreshShapeSection(this.host, s.assetId, s.shapeId) ? refreshClipSection(this.host, s.assetId, s.clipId)
: s.kind === 'annotation' : s.kind === 'shape'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId) ? refreshShapeSection(this.host, s.assetId, s.shapeId)
: s.kind === 'track' : s.kind === 'annotation'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) ? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'edge' : s.kind === 'track'
? refreshEdgeSection(this.host, s.assetId, s.edgeId) ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.kind === 'song' : s.kind === 'edge'
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds) ? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); : 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(); if (!ok) this.rebuild();
} }
} }
+23 -7
View File
@@ -3,11 +3,13 @@ import { SetSendCommand } from '../../core/commands/songCommands';
import { import {
type EdgeSend, type EdgeSend,
findNode, findNode,
type Graph,
type GraphEdge, type GraphEdge,
isStateNode, isStateNode,
resolveDef, resolveDef,
stateName, stateName,
} from '../../core/graph'; } from '../../core/graph';
import { routedKind, sendsTapped } from '../../core/music/projectRouting';
import { SEND_TAPS, sendOf } from '../../core/music/routing'; import { SEND_TAPS, sendOf } from '../../core/music/routing';
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../core/music/song'; import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../core/music/song';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
@@ -47,15 +49,18 @@ export interface EdgeDescription {
/** What flows through it, as the output names it. */ /** What flows through it, as the output names it. */
type: string; type: string;
name: 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; 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 `?`. */ /** 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 { 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); 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 lookup = graphLookup(project);
const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => { const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => {
const node = findNode(graph, side.node); const node = findNode(graph, side.node);
@@ -78,10 +83,17 @@ export function describeEdge(project: Project, assetId: string, edgeId: string):
input: to.port, input: to.port,
type: from.type, type: from.type,
name: edge.label ?? '', 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. */ /** Structure of a link selection: how many, and for one of them what it joins. */
export function edgeSignature( export function edgeSignature(
project: Project, project: Project,
@@ -140,15 +152,19 @@ export function buildEdgeSection(
onInput: () => {}, onInput: () => {},
onChange: (next) => set({ volume: Number(next) }), 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, { const tap = createFieldRow(host, SEND_TAP, send.tap, {
onInput: () => {}, onInput: () => {},
onChange: (next) => set({ tap: SEND_TAPS.find((t) => t.value === textOf(next))?.value ?? 'post' }), 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.fields.set(SEND_TAP.key, tap);
host.body.add(volume.row);
host.body.add(tap.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 { } else {
fact('Input', d.input); fact('Input', d.input);
fact('Type', d.type); fact('Type', d.type);
+7 -11
View File
@@ -20,7 +20,7 @@ import {
soundEntries, soundEntries,
} from '../../core/properties/assetRef'; } from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx'; 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 { keyName } from '../../core/keys';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs'; import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
@@ -403,19 +403,15 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
label: assetRefLabel, label: assetRefLabel,
choices: (project, def) => { choices: (project, def) => {
const extras = def.kind === 'asset' ? def : undefined; 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 [ return [
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []), ...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []), ...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
...assetRefChoices(project, extras?.assetTypes) ...assetRefChoices(project, extras?.assetTypes).flatMap((asset) =>
.filter((asset) => !left.has(asset.id)) // A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
.flatMap((asset) => asset.type === 'sound'
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)". ? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
asset.type === 'sound' : [{ id: asset.id, label: assetChoiceLabel(asset) }],
? 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, TRANSITION_PARAM,
variablesSourceOf, variablesSourceOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; 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 { type PropertyValue, textOf } from '../../core/model/Properties';
import { import {
isListNode, 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. */ /** 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. */ /** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void { 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 { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui'; import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music'; import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer'; import { playedRoute } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport'; import { claimTransport, releaseTransport } from '../music/transport';
@@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button; readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */ /** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' }); 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( readonly music = new Music(
() => this.state.project, () => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}), () => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => { () => {
const asset = this.asset(); const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props)); 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 type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui'; import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music'; import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer'; import { playedRoute } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { import {
type InputDevice, type InputDevice,
@@ -92,10 +92,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly snap = new SnapToggle(() => this.routing); readonly snap = new SnapToggle(() => this.routing);
private shown: SongView = 'timeline'; private shown: SongView = 'timeline';
private readonly pane = new VStack(); 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( readonly music = new Music(
() => this.state.project, () => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}), () => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => { () => {
const asset = this.asset(); const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props)); 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(); this.stop();
claimTransport(this.halt); claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset); const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352), its bus and the master, as the game hears it (#325). // Through its sound FX (#352) and along its routing (#351), as the game hears it.
const bus = playedBus(this.state.project, '', props); const bus = playedBus(this.state.project, '', asset.id);
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE; const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
const file = playedSoundOf({ asset, clip }); const file = playedSoundOf({ asset, clip });
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); 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; if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset); const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } }; const fx = { plan, params: { ...this.values } };
// On the bus of the sound, as the game would play it there (#325). // Along the routing of the sound, as the game would play it there (#351).
const bus = playedBus(this.state.project, '', props); const bus = playedBus(this.state.project, '', sound.asset.id);
const file = playedSoundOf(sound); const file = playedSoundOf(sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice); 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 { PlaceableAssetType } from '../core/model/AssetTypes';
import type { VisibilityPart } from '../core/model/sceneVisibility'; import type { VisibilityPart } from '../core/model/sceneVisibility';
import type { ValuePath } from '../core/properties/valuePath'; import type { ValuePath } from '../core/properties/valuePath';
import { KeepRoutingCommand } from '../core/commands/routingCommands';
import { settleRouting } from '../core/music/projectRouting';
export type Selection = export type Selection =
| { kind: 'asset'; assetId: string } | { kind: 'asset'; assetId: string }
@@ -310,6 +312,8 @@ export class EditorState {
this.paused = false; this.paused = false;
this.project = project; this.project = project;
this.revision = revision; 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.commands = new CommandStack(project);
this.selection = null; this.selection = null;
this.openedValues = null; this.openedValues = null;
@@ -409,7 +413,7 @@ export class EditorState {
*/ */
execute(command: Command): boolean { execute(command: Command): boolean {
return this.runEdit(() => { return this.runEdit(() => {
this.commands.execute(command); this.commands.execute(new KeepRoutingCommand(command));
return true; return true;
}); });
} }
+11 -5
View File
@@ -31,8 +31,9 @@ import {
songTempo, songTempo,
SOUND_ITEM_MAX_DB, SOUND_ITEM_MAX_DB,
} from '../../core/music/song'; } from '../../core/music/song';
import type { SequencePlay } from '../../core/music/playbacks';
import { songBuses } from '../../core/music/songBuses'; 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 { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity'; import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem'; import { PhysicsSystem } from '../physics/PhysicsSystem';
@@ -295,7 +296,7 @@ function playSound(
loop ?? loopsOf(props), loop ?? loopsOf(props),
owner, owner,
fx ?? DRY_VOICE, 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). */ /** 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 { 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}`; 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 * 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 * 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, loop: loop ? { start: 0, end: length } : null,
needs: sequenceNeeds(project, sequence), needs: sequenceNeeds(project, sequence),
...(fx && { fx }), ...(fx && { fx }),
bus: playedBus(project, bus, asset.props), ...heardOn(playedRoute(project, bus, patternId)),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
}, },
owner, owner,
@@ -431,7 +437,7 @@ function playSong(
fadeIn: heldFade(fadeIn), fadeIn: heldFade(fadeIn),
buses: songBuses(project, song), buses: songBuses(project, song),
...(fx && { fx }), ...(fx && { fx }),
bus: playedBus(project, bus, song.props), ...heardOn(playedRoute(project, bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, owner), strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip), 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 { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing'; import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
import type { SendTap } from '../core/graph/Graph'; 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 { limiterLatency } from '../core/soundfx/dynamics';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler'; import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song'; import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
@@ -632,6 +632,7 @@ export class SoundLibrary implements AudioSink {
this.cutPan(bus); this.cutPan(bus);
bus.pan.connect(this.outOf(target)); bus.pan.connect(this.outOf(target));
bus.into = target; bus.into = target;
this.alignSends();
} }
/** /**
@@ -714,9 +715,10 @@ export class SoundLibrary implements AudioSink {
private async applyMix(): Promise<void> { private async applyMix(): Promise<void> {
const names = new Set(this.mix.map((bus) => bus.name)); const names = new Set(this.mix.map((bus) => bus.name));
const waits: Promise<void>[] = []; 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); 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); this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
waits.push(this.setFx(bus, fx)); 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; if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++; const id = this.nextId++;
const buses = play.buses ?? []; 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 tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : ''); const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play; 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), * 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 masterName = groupBus(id, '');
const master = this.bus(masterName, into); const master = this.bus(masterName, into);
master.gain.gain.value = 0; master.gain.gain.value = 0;
master.set = { gain: 0, pan: 0 }; master.set = { gain: 0, pan: 0 };
master.fx = fx; master.fx = fx;
if (sends) this.setSends(master, sends);
const names = new Set(tracks.map((track) => track.name)); const names = new Set(tracks.map((track) => track.name));
const inside = (bus: string | undefined) => const inside = (bus: string | undefined) =>
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName; bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;
+8 -6
View File
@@ -5,7 +5,7 @@ import {
type SchedulerOptions, type SchedulerOptions,
} from '../core/music/Scheduler'; } from '../core/music/Scheduler';
import { sequenceNeeds } from '../core/music/needs'; 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 { BusLevel } from '../core/music/playbacks';
import type { Sequence } from '../core/music/sequence'; import type { Sequence } from '../core/music/sequence';
import type { Project } from '../core/model/Project'; import type { Project } from '../core/model/Project';
@@ -52,13 +52,13 @@ export class Music {
readonly clips: PlannedClip[] = []; readonly clips: PlannedClip[] = [];
/** /**
* `output` is the bus of the mix what it plays is heard on (#325): that of the pattern or song in * `output` is where what it plays goes (#351): along the routing of the pattern or song in the
* the tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of * 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. * that pattern or song (#352), on the way there.
*/ */
constructor( constructor(
private readonly project: () => Project, private readonly project: () => Project,
private readonly output: () => string = () => MASTER_MIX, private readonly output: () => MixRoute = () => MASTER_MIX,
private readonly fx: () => FxVoice | null = () => null, 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 * 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 { heardOn(): string {
const fx = this.fx(); const fx = this.fx();
const output = this.output();
const route = typeof output === 'string' ? { into: output } : { sends: output };
this.fxReady = soundLibrary.setBuses([ 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; return this.outBus;
} }
+2 -2
View File
@@ -128,10 +128,10 @@ function gains(layer: FxPlan | undefined): number[] {
} }
describe('the Sound FX row (#352)', () => { 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[]) { for (const type of ['sound', 'instrument', 'pattern', 'song'] as AssetType[]) {
const keys = schemaFor(type).map((def) => def.key); 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({ expect(schemaFor(type).find((def) => def.key === ASSET_FX_KEY)).toMatchObject({
kind: 'asset', kind: 'asset',
default: '', 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 * Master and buses (#325): what the sound library is set up with, and which bus a sound, a note, a
* which bus a sound, a note, a pattern or a song is heard on. * pattern or a song is heard on – where each goes is the master routing's (#351).
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@@ -21,7 +21,7 @@ import {
reachedBus, reachedBus,
sameMix, sameMix,
} from '../../src/core/music/mixer'; } 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 { schemaForAsset } from '../../src/core/properties/PropertySchema';
import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes'; import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes';
import { GameRun } from '../../src/runtime/GameRun'; import { GameRun } from '../../src/runtime/GameRun';
@@ -34,6 +34,7 @@ import {
makeSpriteScene, makeSpriteScene,
place, place,
roundTrip, roundTrip,
routingOf,
startWorld, startWorld,
} from '../helpers'; } from '../helpers';
@@ -46,9 +47,8 @@ function withBuses(...names: string[]) {
f.stack.execute(command); f.stack.execute(command);
return command.asset.id; return command.asset.id;
}); });
const into = (bus: string, target: string) => const routing = routingOf(f);
f.stack.execute(new SetAssetPropertyCommand(bus, BUS_INTO_KEY, target)); return { ...f, ids, into: (bus: string, target: string) => routing.only(bus, target) };
return { ...f, ids, into };
} }
/** A sound FX that compiles: Input, a Gain, Output. */ /** 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)', () => { 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); expect(isPlaceableType('bus')).toBe(false);
const f = withBuses('Music'); const f = withBuses('Music');
const bus = f.project.assets[0]!; const bus = f.project.assets[0]!;
expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, 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', 'into']); expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx']);
expect(roundTrip(f.project)).toEqual(f.project); 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 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) { for (const type of ['sound', 'instrument', 'pattern', 'song'] as const) {
const command = new CreateAssetCommand(type, type); const command = new CreateAssetCommand(type, type);
f.stack.execute(command); f.stack.execute(command);
expect(schemaForAsset(f.project, command.asset).find((def) => def.key === BUS_KEY)).toMatchObject({ expect(schemaForAsset(f.project, command.asset).map((def) => def.key)).not.toContain('bus');
none: 'Master',
assetTypes: ['bus'],
});
} }
}); expect(schemaForAsset(f.project, f.project.assets[0]!).map((def) => def.key)).not.toContain('into');
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('');
}); });
}); });
@@ -167,15 +134,19 @@ describe('the mix (#325)', () => {
expect(sameMix(before, mixOf(f.project))).toBe(false); 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 f = withBuses('Music', 'SFX');
const [music, sfx] = f.ids as [string, string]; const [music, sfx] = f.ids as [string, string];
expect(playedBus(f.project, '', { bus: music })).toBe(`mix:${music}`); const sound = new CreateSoundCommand('Jump', FILE);
expect(playedBus(f.project, sfx, { bus: music })).toBe(`mix:${sfx}`); f.stack.execute(sound);
expect(playedBus(f.project, MASTER_BUS, { bus: music })).toBe(MASTER_MIX); const id = sound.asset.id;
expect(playedBus(f.project, '', {})).toBe(MASTER_MIX); expect(playedBus(f.project, '', id)).toBe(MASTER_MIX);
// A bus that was deleted leaves it on the master. f.into(id, music);
expect(playedBus(f.project, '', { bus: 'gone' })).toBe(MASTER_MIX); 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', () => { 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)', () => { 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 f = makeSpriteScene();
const bus = new CreateAssetCommand('bus', 'SFX'); const bus = new CreateAssetCommand('bus', 'SFX');
const sound = new CreateSoundCommand('Jump', FILE); const sound = new CreateSoundCommand('Jump', FILE);
f.stack.execute(bus); f.stack.execute(bus);
f.stack.execute(sound); 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); runOnStart('PlaySound', { sound: sound.asset.id, volume: 1 }, f);
const asset = busSink(); const asset = busSink();
startWorld(f, { behaviors: true, audio: asset }).frame(); 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', () => { it('hides the rows that do nothing in a song, only while embedded', () => {
const { f, takeId, blipId } = setup(); const { f, takeId, blipId } = setup();
const take = findAsset(f.project, takeId)!; const take = findAsset(f.project, takeId)!;
expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([ expect(['loop', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([
true,
true, true,
false, false,
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 { make } = withPatterns();
const own = make(); const own = make();
const keys = ['bus', 'tempo', 'sign', 'bars', 'instrument', 'fx']; const keys = ['tempo', 'sign', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([ expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([false, false, false, false, false]);
true,
false,
false,
false,
false,
false,
]);
}); });
it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => { 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 { 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 { requireNodeDef } from '../../src/core/commands/require';
import { CommandError } from '../../src/core/commands/Command'; import { CommandError } from '../../src/core/commands/Command';
import { import {
@@ -82,9 +82,11 @@ describe('node registry', () => {
else if (def.category === 'state') expect(def.run).toBeUndefined(); 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 // 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 // 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') 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`). // 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(); 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`). // 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'], renderLayers: ['Default'],
masterVolume: 1, masterVolume: 1,
masterFx: '', masterFx: '',
routing: { nodes: [], edges: [] },
symbolScale: 1, symbolScale: 1,
playFrom: 'start' as const, playFrom: 'start' as const,
whilePlaying: 'live' 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', 'sign',
'tempo', 'tempo',
'fx', 'fx',
'bus',
]); ]);
}); });
+1 -7
View File
@@ -71,13 +71,7 @@ describe('the song asset (#334)', () => {
const f = makeSong(1); const f = makeSong(1);
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']); expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
expect(f.song().items).toEqual([]); expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual([ expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']);
'tempo',
'sign',
'grid',
'fx',
'bus',
]);
expect(songGrid(f.song().props)).toBe(BAR); expect(songGrid(f.song().props)).toBe(BAR);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T')); f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
expect(songGrid(f.song().props)).toBe(160); expect(songGrid(f.song().props)).toBe(160);
+1 -2
View File
@@ -40,7 +40,6 @@ import {
VOLUME_KEY, VOLUME_KEY,
volumeOf, volumeOf,
} from '../../src/core/properties/sounds'; } from '../../src/core/properties/sounds';
import { BUS_KEY } from '../../src/core/properties/buses';
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
import type { AudioSink } from '../../src/runtime/systems/Audio'; import type { AudioSink } from '../../src/runtime/systems/Audio';
import { import {
@@ -131,7 +130,7 @@ describe('the sound asset', () => {
expect(volumeOf(asset.props)).toBe(1); expect(volumeOf(asset.props)).toBe(1);
expect(loopsOf(asset.props)).toBe(false); expect(loopsOf(asset.props)).toBe(false);
// The cut belongs to a clip of the sound (#357), the sound is the whole file. // 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([ expect(CLIP_ROWS.map((def) => def.key)).toEqual([
START_KEY, START_KEY,
END_KEY, END_KEY,
+1
View File
@@ -189,6 +189,7 @@ describe('a selection of links', () => {
type: 'number', type: 'number',
name: '', name: '',
send: null, send: null,
tapped: false,
}); });
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull(); 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'); 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 { CreateAssetCommand } from '../src/core/commands/assetCommands';
import { CommandStack } from '../src/core/commands/CommandStack'; 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 { AddPortsCommand } from '../src/core/commands/portCommands';
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
import { requireNodeDef } from '../src/core/commands/require'; 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 { worldPosition } from '../src/core/properties/transform';
import { resolvedHolder } from '../src/core/model/instance'; import { resolvedHolder } from '../src/core/model/instance';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties'; 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 type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize'; import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import type { AudioSink } from '../src/runtime/systems/Audio'; import type { AudioSink } from '../src/runtime/systems/Audio';
@@ -65,6 +67,28 @@ export function makeProject(): ProjectFixture {
return { project, stack: new CommandStack(project) }; 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. */ /** 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 { export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
const fixture = makeProject(); 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']); 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 () => { 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); const library = new SoundLibrary(false);
await library.setMix(mix); await library.setMix(mix);
+100 -31
View File
@@ -20,7 +20,7 @@ const DRONE_BYTES = 12044;
export async function sound(t) { export async function sound(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = 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) => const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(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 }))`); .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()') }), 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 // 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the // one; the tab has a node for it and for the sound, each linked to the master. Links drawn
// master's volume is a project setting, one undo step. // 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++) { for (let i = 0; i < 2; i++) {
await clickText('+'); await clickText('+');
await pickAssetType(t, 'Bus'); await pickAssetType(t, 'Bus');
await sleep(150); await sleep(150);
} }
const [busA, busB] = await assetsOfType('bus'); const [busA, busB] = await assetsOfType('bus');
const intoMenu = async () => { await t.clickToolbar('menu');
await select({ kind: 'asset', assetId: busA.id }); await clickText('Master routing');
const at = await json("__gm.inspectorField('Into', true)"); await sleep(300);
await click(at.x, at.y); const routing = () => json('__gm.routing.graph()');
const offered = await json('__gm.overlayTexts()'); const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
await press('Escape'); const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() };
return offered; const labels = await json(
}; `[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`,
const offeredBefore = await intoMenu(); );
await mutate(` await select({ kind: 'asset', assetId: droneId });
const p = __gm.state.project; const busRow = await json("__gm.inspectorField('Bus', true)");
p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`); await select({ kind: 'asset', assetId: busA.id });
const offeredAfter = await intoMenu(); const intoRow = await json("__gm.inspectorField('Into', true)");
check( check(
'"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it', 'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows',
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') && opened.shown &&
JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) && [droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) &&
JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']), [droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) &&
JSON.stringify({ busA, busB, offeredBefore, offeredAfter }), 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(` await mutate(`
const p = __gm.state.project; __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
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)};`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const startedBefore = (await stats()).started; const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y); await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10); await waitFor(stats, (s) => s.started > startedBefore, 10);
await sleep(300);
const buses = await json('__gm.sound.buses()'); const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus; 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); const levelOf = (name) => buses.find((bus) => bus.name === name);
check( check(
'the Sound tab plays the sound on its bus, into the bus that one names, on into the master', 'linked in the routing, the Sound tab plays the sound on its bus, into the bus that one goes to, on into the master',
heardOn === `mix:${busB.id}` && 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:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' && levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 && levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null, levelOf('mix')?.into === null &&
JSON.stringify({ heardOn, buses }), 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'); 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, mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
JSON.stringify({ masterAt, mastered, undone }), JSON.stringify({ masterAt, mastered, undone }),
); );
// The routing starts again from the assets with the next edit: everything on the master.
await mutate(` await mutate(`
const p = __gm.state.project; 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. // 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(` await mutate(`