Route sounds, songs and buses in one Master routing graph (#351)
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:
1 parent
8891f027db
commit
0e73fd85b9
46 files changed
+1253
-311
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@
|
||||
*/
|
||||
import type { EdgeSend, Graph } from '../graph/Graph';
|
||||
import type { Asset, SongAsset } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
import { findGraphAsset, type Project } from '../model/Project';
|
||||
import { settleEmbedded } from '../music/embedded';
|
||||
import { routedKind, sendsTapped } from '../music/projectRouting';
|
||||
import { heldSend, heldSongGraph } from '../music/routing';
|
||||
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
||||
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||||
import type { Command } from './Command';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireEdge, requireSong } from './require';
|
||||
|
||||
export interface SongParts {
|
||||
@@ -75,7 +76,10 @@ export class SetSongCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** How loud a link of a song's routing sends, and where it taps its track (#350). */
|
||||
/**
|
||||
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
|
||||
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
|
||||
*/
|
||||
export class SetSendCommand implements Command {
|
||||
readonly label = 'Set send';
|
||||
private previous: EdgeSend | undefined;
|
||||
@@ -87,10 +91,14 @@ export class SetSendCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
requireSong(project, this.assetId);
|
||||
const holder = findGraphAsset(project, this.assetId);
|
||||
if (holder?.type !== 'song' && holder?.type !== 'routing')
|
||||
throw new CommandError(`Routing ${this.assetId} not found`);
|
||||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||||
const from = holder.graph.nodes.find((n) => n.id === edge.from.node);
|
||||
const tapped = holder.type === 'song' || (from !== undefined && sendsTapped(routedKind(from)));
|
||||
this.previous = edge.send;
|
||||
const held = heldSend(this.send);
|
||||
const held = heldSend(tapped ? this.send : { ...this.send, tap: 'post' });
|
||||
if (held) edge.send = held;
|
||||
else delete edge.send;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isSceneAsset,
|
||||
type SceneAsset,
|
||||
} from './Asset';
|
||||
import { createEmptyGraph, type Graph } from '../graph/Graph';
|
||||
import { DEFAULT_LAYER_NAME } from './collisionLayers';
|
||||
import { DEFAULT_MASTER_VOLUME } from './master';
|
||||
import {
|
||||
@@ -60,6 +61,11 @@ export interface Project {
|
||||
*/
|
||||
masterVolume: number;
|
||||
masterFx: string;
|
||||
/**
|
||||
* Where the sounds, instruments, patterns, songs and buses go (#351): the graph of the Master
|
||||
* routing tab. A project written before there was one has every one of them on the master.
|
||||
*/
|
||||
routing: Graph;
|
||||
/**
|
||||
* Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project
|
||||
* written before there was one reads as 1, the size the icons always had.
|
||||
@@ -90,6 +96,7 @@ export function createEmptyProject(id: string, name: string): Project {
|
||||
renderLayers: [DEFAULT_LAYER_NAME],
|
||||
masterVolume: DEFAULT_MASTER_VOLUME,
|
||||
masterFx: '',
|
||||
routing: createEmptyGraph(),
|
||||
symbolScale: DEFAULT_SYMBOL_SCALE,
|
||||
playFrom: DEFAULT_PLAY_FROM,
|
||||
whilePlaying: DEFAULT_WHILE_PLAYING,
|
||||
@@ -122,7 +129,23 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef
|
||||
return asset && isSceneAsset(asset) ? asset : undefined;
|
||||
}
|
||||
|
||||
export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined {
|
||||
/** The id the master routing (#351) goes by where a graph is looked up; no asset id has a `#`. */
|
||||
export const ROUTING_ID = '#routing';
|
||||
|
||||
/** The master routing (#351) where a graph editor and its commands look for an asset's graph. */
|
||||
export interface RoutingHolder {
|
||||
id: typeof ROUTING_ID;
|
||||
type: 'routing';
|
||||
name: string;
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
export type GraphHolder = GraphAsset | RoutingHolder;
|
||||
|
||||
/** The graph asset by its id – or the master routing by `ROUTING_ID`, which is no asset. */
|
||||
export function findGraphAsset(project: Project, assetId: string): GraphHolder | undefined {
|
||||
if (assetId === ROUTING_ID)
|
||||
return { id: ROUTING_ID, type: 'routing', name: 'Master routing', graph: project.routing };
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset && isGraphAsset(asset) ? asset : undefined;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,11 @@ import { signOf } from './time';
|
||||
export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx'];
|
||||
|
||||
/**
|
||||
* The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the
|
||||
* song's. A pattern keeps its tempo and meter (#363), as one of the project does.
|
||||
* The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern
|
||||
* keeps its tempo and meter (#363), as one of the project does.
|
||||
*/
|
||||
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
|
||||
sound: ['loop', 'bus'],
|
||||
pattern: ['bus'],
|
||||
sound: ['loop'],
|
||||
};
|
||||
|
||||
export function isEmbeddable(type: AssetType): boolean {
|
||||
|
||||
+79
-21
@@ -1,20 +1,38 @@
|
||||
import type { SendTap } from '../graph/Graph';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { BUS_FX_KEY, busInto, busOf, busVolumeOf, MASTER_BUS } from '../properties/buses';
|
||||
import { BUS_FX_KEY, busVolumeOf, MASTER_BUS } from '../properties/buses';
|
||||
import { coerceAs } from '../properties/kinds';
|
||||
import type { FxVoice } from '../soundfx/nodes';
|
||||
import { fxVoiceOf } from './needs';
|
||||
import { isRouted } from './projectRouting';
|
||||
import { sendOf } from './routing';
|
||||
import { dbToGain } from './song';
|
||||
import { DEFAULT_TEMPO } from './time';
|
||||
|
||||
/** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */
|
||||
export const MASTER_MIX = 'mix';
|
||||
|
||||
/** One link of the master routing (#351) as the mix plays it: into that bus, at that level, from that tap. */
|
||||
export interface MixSend {
|
||||
to: string;
|
||||
gain: number;
|
||||
tap: SendTap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where something of the project goes (#351): straight into one bus of the mix, as one link at 0 dB
|
||||
* does, or along its links. No link at all is no send, and silent.
|
||||
*/
|
||||
export type MixRoute = string | readonly MixSend[];
|
||||
|
||||
/** One bus of the mix as the sound library sets it up: its level, its sound FX and where it goes. */
|
||||
export interface MixBus {
|
||||
name: string;
|
||||
volume: number;
|
||||
/** The bus it goes into; null for the master, which is heard. */
|
||||
into: string | null;
|
||||
/** Where it goes instead, along the links of the routing (#351); `into` then says nothing. */
|
||||
sends?: readonly MixSend[];
|
||||
fx: FxVoice | null;
|
||||
}
|
||||
|
||||
@@ -24,29 +42,61 @@ export function mixBusName(busId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The master and every bus of the project (#325), each into the bus it names – or the master, where
|
||||
* that is missing or would make a loop. A sound FX that does not compile is none.
|
||||
* Where an asset goes by the master routing (#351). One the routing has no node for goes into the
|
||||
* master, as every asset of a project written before there was a routing did. A link to what is
|
||||
* neither a bus nor the master counts for nothing.
|
||||
*/
|
||||
export function routeOf(project: Project, assetId: string): MixRoute {
|
||||
const graph = project.routing;
|
||||
if (!graph.nodes.some((node) => node.id === assetId)) return MASTER_MIX;
|
||||
const sends = graph.edges.flatMap((edge) => {
|
||||
const to = edge.to.node;
|
||||
if (edge.from.node !== assetId || (to !== MASTER_BUS && !findAssetOf(project, to, 'bus'))) return [];
|
||||
const { volume, tap } = sendOf(edge);
|
||||
return [{ to: mixBusName(to), gain: dbToGain(volume), tap }];
|
||||
});
|
||||
const only = sends.length === 1 ? sends[0]! : null;
|
||||
return only && only.gain === 1 && only.tap === 'post' ? only.to : sends;
|
||||
}
|
||||
|
||||
/**
|
||||
* The master and every bus of the project (#325), each where the routing sends it (#351); and a bus
|
||||
* of its own for each sound and instrument the routing sends anywhere but straight into one bus, for
|
||||
* all its voices to go through. A sound FX that does not compile is none.
|
||||
*/
|
||||
export function mixOf(project: Project): MixBus[] {
|
||||
const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO);
|
||||
const buses = project.assets.flatMap((asset) =>
|
||||
asset.type === 'bus'
|
||||
? [
|
||||
{
|
||||
name: mixBusName(asset.id),
|
||||
volume: busVolumeOf(asset.props),
|
||||
into: mixBusName(busInto(project, asset.id)),
|
||||
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const routed = (route: MixRoute) =>
|
||||
typeof route === 'string' ? { into: route } : { into: MASTER_MIX, sends: route };
|
||||
const buses = project.assets.flatMap((asset): MixBus[] => {
|
||||
if (asset.type === 'bus')
|
||||
return [
|
||||
{
|
||||
name: mixBusName(asset.id),
|
||||
volume: busVolumeOf(asset.props),
|
||||
...routed(routeOf(project, asset.id)),
|
||||
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
|
||||
},
|
||||
];
|
||||
if ((asset.type !== 'sound' && asset.type !== 'instrument') || !isRouted(asset)) return [];
|
||||
const route = routeOf(project, asset.id);
|
||||
return typeof route === 'string'
|
||||
? []
|
||||
: [{ name: mixBusName(asset.id), volume: 1, into: MASTER_MIX, sends: route, fx: null }];
|
||||
});
|
||||
return [
|
||||
{ name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) },
|
||||
...buses,
|
||||
];
|
||||
}
|
||||
|
||||
const sameSends = (a: readonly MixSend[] | undefined, b: readonly MixSend[] | undefined) =>
|
||||
a === b ||
|
||||
(a !== undefined &&
|
||||
b !== undefined &&
|
||||
a.length === b.length &&
|
||||
a.every((send, i) => send.to === b[i]!.to && send.gain === b[i]!.gain && send.tap === b[i]!.tap));
|
||||
|
||||
/** Whether two mixes set the buses up the same: the same plan of a sound FX is the same object. */
|
||||
export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
|
||||
return (
|
||||
@@ -57,6 +107,7 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
|
||||
bus.name === other.name &&
|
||||
bus.volume === other.volume &&
|
||||
bus.into === other.into &&
|
||||
sameSends(bus.sends, other.sends) &&
|
||||
bus.fx?.plan === other.fx?.plan &&
|
||||
bus.fx?.tempo === other.fx?.tempo
|
||||
);
|
||||
@@ -65,13 +116,20 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bus of the mix something is heard on: the one a node picked, else the one its asset names.
|
||||
* The bus of the mix a voice of a sound or an instrument is heard on: the one a node picked, else
|
||||
* where the routing sends the asset (#351) – its own bus, where that is more than one bus.
|
||||
* A pick naming no bus any more is the master, as a deleted bus would leave it.
|
||||
*/
|
||||
export function playedBus(project: Project, picked: string, assetProps: PropertyBag): string {
|
||||
const id = picked !== '' ? picked : busOf(assetProps);
|
||||
if (id === MASTER_BUS || id === '') return MASTER_MIX;
|
||||
return findAssetOf(project, id, 'bus') ? mixBusName(id) : MASTER_MIX;
|
||||
export function playedBus(project: Project, picked: string, assetId: string): string {
|
||||
const route = playedRoute(project, picked, assetId);
|
||||
return typeof route === 'string' ? route : mixBusName(assetId);
|
||||
}
|
||||
|
||||
/** Where a playing of a pattern or a song goes: the bus a node picked, else along its routing (#351). */
|
||||
export function playedRoute(project: Project, picked: string, assetId: string): MixRoute {
|
||||
if (picked === MASTER_BUS) return MASTER_MIX;
|
||||
if (picked !== '') return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : MASTER_MIX;
|
||||
return routeOf(project, assetId);
|
||||
}
|
||||
|
||||
/** The bus of the mix a node reaches where an empty pick is the master: Set bus volume, Bus volume. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SendTap } from '../graph/Graph';
|
||||
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||
import type { MixSend } from './mixer';
|
||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import type { LoopRange, Sequence } from './sequence';
|
||||
|
||||
@@ -47,6 +48,8 @@ export interface SequencePlay {
|
||||
fx?: FxVoice;
|
||||
/** The bus of the mix the whole playing is heard on (#325); the master where left out. */
|
||||
bus?: string;
|
||||
/** Where the whole playing goes instead, along the links of the master routing (#351). */
|
||||
sends?: readonly MixSend[];
|
||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -39,3 +39,57 @@ export const SongOutputNode = registerNode({
|
||||
export function isSongRoutingDef(def: NodeDef): boolean {
|
||||
return def.type === SONG_TRACK || def.type === SONG_OUTPUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes of the master routing (#351): one per sound, instrument, pattern, song and bus of the
|
||||
* project, named and typed after its asset, and the master. Only a bus and the master take links in.
|
||||
*/
|
||||
export const ROUTED_ASSET = 'RoutedAsset';
|
||||
export const ROUTED_MASTER = 'RoutedMaster';
|
||||
export const ROUTED_NAME = 'name';
|
||||
export const ROUTED_KIND = 'kind';
|
||||
|
||||
const KIND_WORDS: Record<string, string> = {
|
||||
sound: 'Sound',
|
||||
instrument: 'Instrument',
|
||||
pattern: 'Pattern',
|
||||
song: 'Song',
|
||||
bus: 'Bus',
|
||||
};
|
||||
|
||||
export const RoutedAssetNode = registerNode({
|
||||
type: ROUTED_ASSET,
|
||||
label: 'Asset',
|
||||
category: 'sound',
|
||||
description:
|
||||
'A sound, an instrument, a pattern, a song or a bus of the project: all it plays, sent on to every link.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
fixed: true,
|
||||
acyclic: true,
|
||||
derivePorts: ({ node }) => {
|
||||
const kind = textOf(node.params[ROUTED_KIND] ?? '');
|
||||
return {
|
||||
label: textOf(node.params[ROUTED_NAME] ?? '') || 'Asset',
|
||||
inputs: kind === 'bus' ? [audioIn] : [],
|
||||
outputs: [{ ...audioOut, label: KIND_WORDS[kind] ?? 'out' }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const RoutedMasterNode = registerNode({
|
||||
type: ROUTED_MASTER,
|
||||
label: 'Master',
|
||||
category: 'sound',
|
||||
description: 'Where the game is heard: through the volume and the sound FX of the master to the speakers.',
|
||||
inputs: [audioIn],
|
||||
outputs: [],
|
||||
params: [],
|
||||
fixed: true,
|
||||
acyclic: true,
|
||||
});
|
||||
|
||||
export function isMasterRoutingDef(def: NodeDef): boolean {
|
||||
return def.type === ROUTED_ASSET || def.type === ROUTED_MASTER;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ import {
|
||||
} from '../music/song';
|
||||
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
|
||||
import { ASSET_FX_KEY } from './assetFx';
|
||||
import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses';
|
||||
import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses';
|
||||
import { Theme } from '../../ui';
|
||||
|
||||
/**
|
||||
@@ -133,9 +133,7 @@ import { Theme } from '../../ui';
|
||||
* produces fractions, and the field rounds the value away rather than hiding it. Without
|
||||
* `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick,
|
||||
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
|
||||
* "from the asset" (#352), and `intoFor` names the bus the row is
|
||||
* the Into of, which leaves out that bus and every one going into it when the picker opens (#325);
|
||||
* `uploadedOnly` keeps drawn pictures out
|
||||
* "from the asset" (#352); `uploadedOnly` keeps drawn pictures out
|
||||
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
|
||||
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
|
||||
*/
|
||||
@@ -149,7 +147,6 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
none?: string;
|
||||
master?: boolean;
|
||||
noFx?: boolean;
|
||||
intoFor?: string;
|
||||
}
|
||||
: K extends 'texture'
|
||||
? { uploadedOnly?: boolean }
|
||||
@@ -485,16 +482,6 @@ const SLICE_SCALE_ROW: PropertyDef = {
|
||||
step: 0.1,
|
||||
};
|
||||
|
||||
/** The bus a sound, an instrument, a pattern or a song is heard on (#325). */
|
||||
const BUS_ROW: PropertyDef = {
|
||||
key: BUS_KEY,
|
||||
label: 'Bus',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['bus'],
|
||||
none: 'Master',
|
||||
};
|
||||
|
||||
/** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
|
||||
const FX_ROW: PropertyDef = {
|
||||
key: ASSET_FX_KEY,
|
||||
@@ -706,12 +693,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||||
soundfx: [],
|
||||
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
|
||||
instrument: [FX_ROW, BUS_ROW],
|
||||
instrument: [FX_ROW],
|
||||
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
|
||||
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
|
||||
pattern: [
|
||||
@@ -744,7 +730,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
step: 1,
|
||||
},
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
|
||||
song: [
|
||||
@@ -766,13 +751,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
options: SONG_GRID_OPTIONS,
|
||||
},
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// Where sounds are mixed (#325): how loud, through which sound FX, and on into which bus.
|
||||
// Where sounds are mixed (#325): how loud and through which sound FX. Where it goes on is the
|
||||
// master routing's (#351).
|
||||
bus: [
|
||||
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
|
||||
{ ...BUS_ROW, key: BUS_INTO_KEY, label: 'Into' },
|
||||
],
|
||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||
// nodes add their rows after these – see `schemaForBag`.
|
||||
@@ -1121,11 +1105,6 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
|
||||
// Nothing but declarations: the starting values live in `props` under the very same keys.
|
||||
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
// A bus never goes into itself, nor into one that already goes into it.
|
||||
if (asset.type === 'bus')
|
||||
return FULL_SCHEMAS.bus.map((def) =>
|
||||
def.key === BUS_INTO_KEY && def.kind === 'asset' ? { ...def, intoFor: asset.id } : def,
|
||||
);
|
||||
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
|
||||
if (asset.type === 'particles')
|
||||
return sourceRows(
|
||||
|
||||
@@ -1,59 +1,18 @@
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { coerceAs } from './kinds';
|
||||
|
||||
/** The bus a sound, an instrument, a pattern or a song is heard on (#325); empty is the master. */
|
||||
export const BUS_KEY = 'bus';
|
||||
/** How loud a bus is, 0–1. */
|
||||
export const BUS_VOLUME_KEY = 'volume';
|
||||
/** The sound FX all of a bus goes through, before its volume. */
|
||||
export const BUS_FX_KEY = 'fx';
|
||||
/** The bus a bus goes into; empty is the master. */
|
||||
export const BUS_INTO_KEY = 'into';
|
||||
|
||||
/**
|
||||
* What a node picks to name the master where empty means something else: "from the asset" on a
|
||||
* Play node, "no bus" on Set sound FX parameters. No asset id has a `#`.
|
||||
* Play node, "no bus" on Set sound FX parameters. No asset id has a `#`. It is also the id of the
|
||||
* master in the master routing (#351).
|
||||
*/
|
||||
export const MASTER_BUS = '#master';
|
||||
|
||||
export function busOf(bag: PropertyBag): string {
|
||||
return coerceAs(bag[BUS_KEY], 'asset', '');
|
||||
}
|
||||
|
||||
export function busVolumeOf(bag: PropertyBag): number {
|
||||
return Math.max(0, Math.min(1, coerceAs(bag[BUS_VOLUME_KEY], 'number', 1)));
|
||||
}
|
||||
|
||||
/** The bus a bus says it goes into, where that is a bus; '' for the master. */
|
||||
function namedInto(project: Project, busId: string): string {
|
||||
const bus = findAssetOf(project, busId, 'bus');
|
||||
const into = bus ? coerceAs(bus.props[BUS_INTO_KEY], 'asset', '') : '';
|
||||
return into !== '' && findAssetOf(project, into, 'bus') ? into : '';
|
||||
}
|
||||
|
||||
/** Whether following the buses on from `from` comes to `to`. */
|
||||
function reaches(project: Project, from: string, to: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
for (let at = from; at !== '' && !seen.has(at); at = namedInto(project, at)) {
|
||||
if (at === to) return true;
|
||||
seen.add(at);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bus a bus goes into, '' for the master. One that would come round to itself goes into the
|
||||
* master instead, so however a project was written the buses never run in a loop.
|
||||
*/
|
||||
export function busInto(project: Project, busId: string): string {
|
||||
const into = namedInto(project, busId);
|
||||
return into !== '' && reaches(project, into, busId) ? '' : into;
|
||||
}
|
||||
|
||||
/** The buses a bus cannot go into: itself and every one that already goes into it. */
|
||||
export function busesInto(project: Project, busId: string): string[] {
|
||||
return project.assets
|
||||
.filter((asset) => asset.type === 'bus' && reaches(project, asset.id, busId))
|
||||
.map((asset) => asset.id);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import { heldPatternNotes } from '../music/pattern';
|
||||
import { isEmbeddable } from '../music/embedded';
|
||||
import { heldSongItems, heldSongTracks } from '../music/song';
|
||||
import { heldSend, heldSongGraph } from '../music/routing';
|
||||
import { isSongRoutingDef } from '../music/routingNodes';
|
||||
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
|
||||
import { heldRouting } from '../music/projectRouting';
|
||||
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||
import type {
|
||||
@@ -340,6 +341,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
renderLayers: renderLayersOf(r),
|
||||
masterVolume: masterVolumeOf(r),
|
||||
masterFx: masterFxOf(r),
|
||||
routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false),
|
||||
symbolScale: symbolScaleOf(r),
|
||||
playFrom: playFromOf(r),
|
||||
whilePlaying: whilePlayingOf(r),
|
||||
|
||||
+27
-12
@@ -1,6 +1,6 @@
|
||||
import type { Application, Ticker } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf } from '../core/music/mixer';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
@@ -94,6 +94,7 @@ import type { PluginManager } from './plugins/loader';
|
||||
import { PluginsPane } from './plugins/PluginsPane';
|
||||
import { ManagementPane } from './users/ManagementPane';
|
||||
import { UsersPane } from './users/UsersPane';
|
||||
import { RoutingEditor } from './routing/RoutingEditor';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -115,6 +116,7 @@ export type CentreTab =
|
||||
| 'tile'
|
||||
| 'container'
|
||||
| 'variables'
|
||||
| 'routing'
|
||||
| 'management';
|
||||
|
||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||
@@ -123,10 +125,10 @@ export type PluginTabId = `plugin:${string}`;
|
||||
export type TabId = CentreTab | PluginTabId;
|
||||
/**
|
||||
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
|
||||
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
|
||||
* opens and closes like one.
|
||||
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
|
||||
* (#351) are no documents, but they open and close like one.
|
||||
*/
|
||||
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
|
||||
export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management';
|
||||
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||||
export interface EditorAccount {
|
||||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||||
@@ -144,7 +146,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||||
|
||||
type FixedTab = (typeof FIXED_TABS)[number];
|
||||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'management'>;
|
||||
|
||||
const FIXED_TABS = ['scene', 'game'] as const;
|
||||
|
||||
@@ -168,6 +170,7 @@ const PANE_ORDER = [
|
||||
'tile',
|
||||
'container',
|
||||
'variables',
|
||||
'routing',
|
||||
'management',
|
||||
] as const;
|
||||
|
||||
@@ -192,6 +195,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
variables: 'Variables',
|
||||
routing: 'Master routing',
|
||||
management: 'User management',
|
||||
};
|
||||
|
||||
@@ -202,7 +206,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||
};
|
||||
|
||||
function isDocument(tab: StripTab): tab is DocumentId {
|
||||
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
|
||||
return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:');
|
||||
}
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
@@ -245,6 +249,7 @@ export class Editor implements PlayHost {
|
||||
readonly tileEditor: TileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly variablesEditor: VariablesEditor;
|
||||
readonly routingEditor: RoutingEditor;
|
||||
/** What each open Variables tab shows, by its document. */
|
||||
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
||||
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
|
||||
@@ -335,6 +340,7 @@ export class Editor implements PlayHost {
|
||||
this.tileEditor = new TileEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
this.variablesEditor = new VariablesEditor(state);
|
||||
this.routingEditor = new RoutingEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.tilemapPanel = new TilemapPanel(state);
|
||||
@@ -383,7 +389,10 @@ export class Editor implements PlayHost {
|
||||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
@@ -453,6 +462,7 @@ export class Editor implements PlayHost {
|
||||
game: this.gamePane,
|
||||
...this.assetEditors,
|
||||
variables: this.variablesEditor,
|
||||
routing: this.routingEditor,
|
||||
management: this.managementPane,
|
||||
};
|
||||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||
@@ -532,7 +542,8 @@ export class Editor implements PlayHost {
|
||||
// creating it) is for – see the `open` event below.
|
||||
state.on('selection', () => {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
|
||||
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
|
||||
else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
|
||||
this.openDocument(`asset:${s.assetId}`);
|
||||
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
|
||||
this.openDocument(`asset:${s.holderId}`);
|
||||
@@ -791,6 +802,7 @@ export class Editor implements PlayHost {
|
||||
if (kind && kind in this.assetEditors)
|
||||
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||||
this.valueTargets.delete(doc);
|
||||
if (doc === 'routing') this.routingEditor.close();
|
||||
if (!wasShowing) return;
|
||||
if (this.documents.active) this.showDocument(this.documents.active);
|
||||
else this.showTab('scene');
|
||||
@@ -807,8 +819,8 @@ export class Editor implements PlayHost {
|
||||
if (doc) this.openDocument(doc);
|
||||
return;
|
||||
}
|
||||
if (tab === 'management') {
|
||||
this.openDocument('management');
|
||||
if (tab === 'management' || tab === 'routing') {
|
||||
this.openDocument(tab);
|
||||
return;
|
||||
}
|
||||
if (tab in this.assetEditors) {
|
||||
@@ -856,7 +868,7 @@ export class Editor implements PlayHost {
|
||||
const target = this.valueTargets.get(doc);
|
||||
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
|
||||
}
|
||||
if (doc === 'management') return 'management';
|
||||
if (doc === 'management' || doc === 'routing') return doc;
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||||
return kind && kind !== 'scene' ? kind : null;
|
||||
@@ -870,12 +882,14 @@ export class Editor implements PlayHost {
|
||||
if (kind === 'variables' && target) this.variablesEditor.show(target);
|
||||
// Read on every showing: an account may have been changed from another tab in the meantime.
|
||||
if (kind === 'management') void this.usersPane?.reload();
|
||||
if (kind === 'routing') this.routingEditor.open();
|
||||
this.display(kind, doc);
|
||||
}
|
||||
|
||||
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
||||
private labelOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||||
if (doc === 'routing') return TAB_LABELS.routing;
|
||||
const target = this.valueTargets.get(doc);
|
||||
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
|
||||
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
|
||||
@@ -885,6 +899,7 @@ export class Editor implements PlayHost {
|
||||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||||
private iconOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
|
||||
if (doc === 'routing') return ASSET_ICON_SVG.bus;
|
||||
const target = this.valueTargets.get(doc);
|
||||
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
@@ -900,7 +915,7 @@ export class Editor implements PlayHost {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!this.tabs.has(doc) && kind)
|
||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||||
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||||
kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||||
icon: this.iconOf(doc),
|
||||
onClose: () => this.closeDocument(doc),
|
||||
});
|
||||
|
||||
@@ -105,9 +105,10 @@ export class ProjectSession {
|
||||
|
||||
/**
|
||||
* The one menu of the top bar (#183): everything that is about the project rather than a scene.
|
||||
* `more` comes last – what the editor adds that is not about the project (#179).
|
||||
* `project` is what the editor adds about it, after its settings (#351); `more` comes last – what
|
||||
* the editor adds that is not about the project (#179).
|
||||
*/
|
||||
openMainMenu(anchor: Widget, more: MenuEntry[] = []): void {
|
||||
openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void {
|
||||
const items: MenuEntry[] = [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
@@ -115,6 +116,7 @@ export class ProjectSession {
|
||||
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
||||
{ label: 'Release…', onSelect: () => this.openRelease() },
|
||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||
...project,
|
||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||
...more,
|
||||
];
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
namesBehavior,
|
||||
SHADER_CALL,
|
||||
} from '../core/graph';
|
||||
import type { GraphAsset } from '../core/model/Asset';
|
||||
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { isLiveInstance } from '../core/model/instance';
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import {
|
||||
findGraphAsset,
|
||||
findObjectHolder,
|
||||
type GraphHolder,
|
||||
type ObjectHolder,
|
||||
type Project,
|
||||
} from '../core/model/Project';
|
||||
import { GRID_STEP } from './graph/graphView';
|
||||
import { paletteEntries } from './graph/paletteEntries';
|
||||
import { type EditorState, graphSelection, objectSelection } from './state';
|
||||
@@ -158,7 +163,7 @@ export function pasteObjects(
|
||||
}
|
||||
|
||||
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
|
||||
function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
|
||||
function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean {
|
||||
return paletteEntries(project, asset).some(
|
||||
(entry) =>
|
||||
entry.def.type === node.type &&
|
||||
@@ -170,7 +175,7 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea
|
||||
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
|
||||
function fittingPart(
|
||||
project: Project,
|
||||
asset: GraphAsset,
|
||||
asset: GraphHolder,
|
||||
clip: GraphClip,
|
||||
): { clip: GraphClip; skipped: GraphNode[] } {
|
||||
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { saturationAutoGain } from '../core/soundfx/saturation';
|
||||
import { curveHooks } from './debug/curveHooks';
|
||||
import { soundHooks } from './debug/soundHooks';
|
||||
import { patternHooks } from './debug/patternHooks';
|
||||
import { routingHooks } from './debug/routingHooks';
|
||||
import { songHooks } from './debug/songHooks';
|
||||
import { documentHooks } from './debug/documentHooks';
|
||||
import { graphHooks } from './debug/graphHooks';
|
||||
@@ -50,6 +51,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
...curveHooks(context),
|
||||
...patternHooks(context),
|
||||
...songHooks(context),
|
||||
...routingHooks(context),
|
||||
...soundHooks(context),
|
||||
...renderHooks(context),
|
||||
/**
|
||||
|
||||
@@ -26,14 +26,16 @@ import {
|
||||
soundFxParamsMismatch,
|
||||
startStateOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||
import { masterRoutingProblems } from '../../core/music/projectRouting';
|
||||
import { routingProblems } from '../../core/music/routing';
|
||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||
import { ROUTED_ASSET } from '../../core/music/routingNodes';
|
||||
import { hasAssetEditor } from '../assetTabs';
|
||||
import { findAsset, findGraphAsset, type GraphHolder } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { ViewTween } from '../viewTween';
|
||||
import { BeadClock } from './beads';
|
||||
@@ -281,7 +283,7 @@ export class GraphCanvas extends Widget {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
private asset(): GraphAsset | null {
|
||||
private asset(): GraphHolder | null {
|
||||
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
|
||||
}
|
||||
|
||||
@@ -316,7 +318,9 @@ export class GraphCanvas extends Widget {
|
||||
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
|
||||
: asset.type === 'song'
|
||||
? routingProblems(asset.graph)
|
||||
: null;
|
||||
: asset.type === 'routing'
|
||||
? masterRoutingProblems(asset.graph)
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
@@ -429,7 +433,7 @@ export class GraphCanvas extends Widget {
|
||||
* one held by the selected object's instance. Without a selection nothing is strong – that keeps
|
||||
* "many instances, many states" readable instead of picking one of them at random.
|
||||
*/
|
||||
private stateMarks(asset: GraphAsset): Map<string, 'held' | 'current'> {
|
||||
private stateMarks(asset: GraphHolder): Map<string, 'held' | 'current'> {
|
||||
const marks = new Map<string, 'held' | 'current'>();
|
||||
if (asset.type !== 'statemachine') return marks;
|
||||
const selection = this.state.selection;
|
||||
@@ -665,6 +669,13 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
|
||||
if (node.type === ROUTED_ASSET) {
|
||||
const asset = findAsset(this.state.project, node.id);
|
||||
if (!asset || !hasAssetEditor(asset.type)) return false;
|
||||
this.state.openAsset(node.id);
|
||||
return true;
|
||||
}
|
||||
if (node.type === SHADER_CALL || node.type === INSTRUMENT_SOUND_FX) {
|
||||
const shader = node.type === SHADER_CALL;
|
||||
const id = textOf(node.params[shader ? FUNCTION_PARAM : 'fx'] ?? '');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AddNodeCommand } from '../../core/commands/graphCommands';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import type { GraphHolder } from '../../core/model/Project';
|
||||
import type { Box } from '../framing';
|
||||
import type { EditorState } from '../state';
|
||||
import type { NodeView } from './NodeView';
|
||||
@@ -11,7 +11,7 @@ export interface GraphCanvasHost {
|
||||
readonly state: EditorState;
|
||||
/** The asset on the canvas; empty when there is none. */
|
||||
assetId(): string;
|
||||
asset(): GraphAsset | null;
|
||||
asset(): GraphHolder | null;
|
||||
views(): ReadonlyMap<string, NodeView>;
|
||||
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
|
||||
topDownViews(): Iterable<NodeView>;
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
STATE_NAME_PARAM,
|
||||
terminalNode,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
|
||||
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
|
||||
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import type { GraphHolder, Project } from '../../core/model/Project';
|
||||
import {
|
||||
isParticlePropertyNode,
|
||||
PARTICLE_PROPERTY,
|
||||
@@ -132,14 +132,14 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse
|
||||
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
|
||||
* through (#294) –, and the two terminals drop out once the graph has them.
|
||||
*/
|
||||
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
|
||||
export function paletteEntries(project: Project, asset: GraphHolder): PaletteEntry[] {
|
||||
if (asset.type === 'statemachine') {
|
||||
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
||||
// A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350).
|
||||
if (asset.type === 'song') return [];
|
||||
// A routing holds nodes that come and go on their own: a song's tracks (#350), the assets (#351).
|
||||
if (asset.type === 'song' || asset.type === 'routing') return [];
|
||||
if (asset.type === 'soundfx') {
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
return SOUND_FX_NODES.filter(
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
objectSignature,
|
||||
refreshObjectSection,
|
||||
} from './objectSection';
|
||||
import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection';
|
||||
import { buildSongSection, refreshSongSection, songSignature } from './songSection';
|
||||
import { ROUTING_ID } from '../../core/model/Project';
|
||||
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
|
||||
import { INLINE_LIMIT } from '../variables/target';
|
||||
import { inlineListsIn } from '../variables/VariablesEditor';
|
||||
@@ -130,6 +132,7 @@ export class Inspector extends Panel {
|
||||
new Label('Select an asset, a scene object or a node.', { style: 'muted', wrap: true }),
|
||||
);
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node' && s.assetId === ROUTING_ID) buildRoutingNodeSection(this.host, s.nodeId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId);
|
||||
@@ -147,6 +150,8 @@ export class Inspector extends Panel {
|
||||
const s = this.state.selection;
|
||||
if (!s) return '';
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node' && s.assetId === ROUTING_ID)
|
||||
return routingNodeSignature(this.state.project, s.nodeId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId);
|
||||
@@ -176,23 +181,25 @@ export class Inspector extends Panel {
|
||||
const ok =
|
||||
s.kind === 'asset'
|
||||
? refreshAssetSection(this.host, s.assetId)
|
||||
: s.kind === 'node'
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'clip'
|
||||
? refreshClipSection(this.host, s.assetId, s.clipId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
: s.kind === 'node' && s.assetId === ROUTING_ID
|
||||
? refreshRoutingNodeSection(this.host, s.nodeId)
|
||||
: s.kind === 'node'
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'clip'
|
||||
? refreshClipSection(this.host, s.assetId, s.clipId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { SetSendCommand } from '../../core/commands/songCommands';
|
||||
import {
|
||||
type EdgeSend,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
isStateNode,
|
||||
resolveDef,
|
||||
stateName,
|
||||
} from '../../core/graph';
|
||||
import { routedKind, sendsTapped } from '../../core/music/projectRouting';
|
||||
import { SEND_TAPS, sendOf } from '../../core/music/routing';
|
||||
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../core/music/song';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
@@ -47,15 +49,18 @@ export interface EdgeDescription {
|
||||
/** What flows through it, as the output names it. */
|
||||
type: string;
|
||||
name: string;
|
||||
/** On a link of a song's routing (#350): what it sends; null on any other link. */
|
||||
/** On a link of a song's routing (#350) or of the master routing (#351): what it sends; null on any other link. */
|
||||
send: EdgeSend | null;
|
||||
/** Whether the link may take the sound anywhere but after the level: not out of a sound, an instrument or a pattern. */
|
||||
tapped: boolean;
|
||||
}
|
||||
|
||||
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
|
||||
export function describeEdge(project: Project, assetId: string, edgeId: string): EdgeDescription | null {
|
||||
const graph = findGraphAsset(project, assetId)?.graph;
|
||||
const holder = findGraphAsset(project, assetId);
|
||||
const graph = holder?.graph;
|
||||
const edge = graph?.edges.find((e) => e.id === edgeId);
|
||||
if (!graph || !edge) return null;
|
||||
if (!holder || !graph || !edge) return null;
|
||||
const lookup = graphLookup(project);
|
||||
const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => {
|
||||
const node = findNode(graph, side.node);
|
||||
@@ -78,10 +83,17 @@ export function describeEdge(project: Project, assetId: string, edgeId: string):
|
||||
input: to.port,
|
||||
type: from.type,
|
||||
name: edge.label ?? '',
|
||||
send: findGraphAsset(project, assetId)?.type === 'song' ? sendOf(edge) : null,
|
||||
send: holder.type === 'song' || holder.type === 'routing' ? sendOf(edge) : null,
|
||||
tapped: holder.type === 'song' || sendsTapped(fromKind(graph, edge)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The asset type a link of the master routing (#351) comes out of; `''` for anything else. */
|
||||
function fromKind(graph: Graph, edge: GraphEdge): string {
|
||||
const node = findNode(graph, edge.from.node);
|
||||
return node ? routedKind(node) : '';
|
||||
}
|
||||
|
||||
/** Structure of a link selection: how many, and for one of them what it joins. */
|
||||
export function edgeSignature(
|
||||
project: Project,
|
||||
@@ -140,15 +152,19 @@ export function buildEdgeSection(
|
||||
onInput: () => {},
|
||||
onChange: (next) => set({ volume: Number(next) }),
|
||||
});
|
||||
host.fields.set(SEND_VOLUME.key, volume);
|
||||
host.body.add(volume.row);
|
||||
if (!d.tapped) {
|
||||
addNote(host, 'In decibels, after the volume and the sound FX of the asset.', 'small');
|
||||
return;
|
||||
}
|
||||
const tap = createFieldRow(host, SEND_TAP, send.tap, {
|
||||
onInput: () => {},
|
||||
onChange: (next) => set({ tap: SEND_TAPS.find((t) => t.value === textOf(next))?.value ?? 'post' }),
|
||||
});
|
||||
host.fields.set(SEND_VOLUME.key, volume);
|
||||
host.fields.set(SEND_TAP.key, tap);
|
||||
host.body.add(volume.row);
|
||||
host.body.add(tap.row);
|
||||
addNote(host, 'In decibels. Mode says where on its track the link takes the sound.', 'small');
|
||||
addNote(host, 'In decibels. Mode says where on its track or bus the link takes the sound.', 'small');
|
||||
} else {
|
||||
fact('Input', d.input);
|
||||
fact('Type', d.type);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
soundEntries,
|
||||
} from '../../core/properties/assetRef';
|
||||
import { NO_FX } from '../../core/properties/assetFx';
|
||||
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
|
||||
import { MASTER_BUS } from '../../core/properties/buses';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||||
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
|
||||
@@ -403,19 +403,15 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
label: assetRefLabel,
|
||||
choices: (project, def) => {
|
||||
const extras = def.kind === 'asset' ? def : undefined;
|
||||
// Asked as the menu opens: another bus may have been sent into this one meanwhile.
|
||||
const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []);
|
||||
return [
|
||||
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
|
||||
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
|
||||
...assetRefChoices(project, extras?.assetTypes)
|
||||
.filter((asset) => !left.has(asset.id))
|
||||
.flatMap((asset) =>
|
||||
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
|
||||
asset.type === 'sound'
|
||||
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
|
||||
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
|
||||
),
|
||||
...assetRefChoices(project, extras?.assetTypes).flatMap((asset) =>
|
||||
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
|
||||
asset.type === 'sound'
|
||||
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
|
||||
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,9 +26,8 @@ import {
|
||||
TRANSITION_PARAM,
|
||||
variablesSourceOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import {
|
||||
isListNode,
|
||||
@@ -106,7 +105,7 @@ function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string {
|
||||
}
|
||||
|
||||
/** A state machine declares no variables; its one node type has none to name. */
|
||||
const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||||
const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||||
|
||||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||||
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { DEFAULT_TEMPO } from '../../core/music/time';
|
||||
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
|
||||
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { playedBus } from '../../core/music/mixer';
|
||||
import { playedRoute } from '../../core/music/mixer';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
@@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
readonly ghostsButton: Button;
|
||||
/** Where the ghost notes come from: song, track and bar. */
|
||||
readonly ghostSource = new Label('', { style: 'muted' });
|
||||
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
|
||||
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */
|
||||
readonly music = new Music(
|
||||
() => this.state.project,
|
||||
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
|
||||
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
|
||||
() => {
|
||||
const asset = this.asset();
|
||||
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { assetFxOf } from '../../core/properties/assetFx';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { playedBus } from '../../core/music/mixer';
|
||||
import { playedRoute } from '../../core/music/mixer';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import {
|
||||
type InputDevice,
|
||||
@@ -92,10 +92,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
readonly snap = new SnapToggle(() => this.routing);
|
||||
private shown: SongView = 'timeline';
|
||||
private readonly pane = new VStack();
|
||||
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
|
||||
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */
|
||||
readonly music = new Music(
|
||||
() => this.state.project,
|
||||
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
|
||||
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
|
||||
() => {
|
||||
const asset = this.asset();
|
||||
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props));
|
||||
|
||||
@@ -152,8 +152,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
|
||||
this.stop();
|
||||
claimTransport(this.halt);
|
||||
const props = resolveAssetProps(this.state.project, asset);
|
||||
// Through its sound FX (#352), its bus and the master, as the game hears it (#325).
|
||||
const bus = playedBus(this.state.project, '', props);
|
||||
// Through its sound FX (#352) and along its routing (#351), as the game hears it.
|
||||
const bus = playedBus(this.state.project, '', asset.id);
|
||||
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
|
||||
const file = playedSoundOf({ asset, clip });
|
||||
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
|
||||
|
||||
@@ -106,8 +106,8 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
||||
if (!sound?.asset.file || !plan) return;
|
||||
const props = resolveAssetProps(this.state.project, sound.asset);
|
||||
const fx = { plan, params: { ...this.values } };
|
||||
// On the bus of the sound, as the game would play it there (#325).
|
||||
const bus = playedBus(this.state.project, '', props);
|
||||
// Along the routing of the sound, as the game would play it there (#351).
|
||||
const bus = playedBus(this.state.project, '', sound.asset.id);
|
||||
const file = playedSoundOf(sound);
|
||||
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
|
||||
if (voice) this.voices.push(voice);
|
||||
|
||||
+5
-1
@@ -28,6 +28,8 @@ import type { GameScale, PlayFrom } from '../core/model/playSettings';
|
||||
import type { PlaceableAssetType } from '../core/model/AssetTypes';
|
||||
import type { VisibilityPart } from '../core/model/sceneVisibility';
|
||||
import type { ValuePath } from '../core/properties/valuePath';
|
||||
import { KeepRoutingCommand } from '../core/commands/routingCommands';
|
||||
import { settleRouting } from '../core/music/projectRouting';
|
||||
|
||||
export type Selection =
|
||||
| { kind: 'asset'; assetId: string }
|
||||
@@ -310,6 +312,8 @@ export class EditorState {
|
||||
this.paused = false;
|
||||
this.project = project;
|
||||
this.revision = revision;
|
||||
// Every sound, instrument, pattern, song and bus has its node in the Master routing (#351).
|
||||
settleRouting(project);
|
||||
this.commands = new CommandStack(project);
|
||||
this.selection = null;
|
||||
this.openedValues = null;
|
||||
@@ -409,7 +413,7 @@ export class EditorState {
|
||||
*/
|
||||
execute(command: Command): boolean {
|
||||
return this.runEdit(() => {
|
||||
this.commands.execute(command);
|
||||
this.commands.execute(new KeepRoutingCommand(command));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
songTempo,
|
||||
SOUND_ITEM_MAX_DB,
|
||||
} from '../../core/music/song';
|
||||
import type { SequencePlay } from '../../core/music/playbacks';
|
||||
import { songBuses } from '../../core/music/songBuses';
|
||||
import { playedBus, reachedBus } from '../../core/music/mixer';
|
||||
import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer';
|
||||
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
|
||||
import type { Entity } from '../Entity';
|
||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||
@@ -295,7 +296,7 @@ function playSound(
|
||||
loop ?? loopsOf(props),
|
||||
owner,
|
||||
fx ?? DRY_VOICE,
|
||||
playedBus(project, bus, props),
|
||||
playedBus(project, bus, ref.asset.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,7 +307,7 @@ export function instrumentFx(project: Project, instrumentId: string, picked: str
|
||||
|
||||
/** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */
|
||||
export function instrumentBus(project: Project, instrumentId: string, picked: string): string {
|
||||
return playedBus(project, picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
|
||||
return playedBus(project, picked, instrumentId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,6 +358,11 @@ export function instrumentVoiceName(instrumentId: string): string {
|
||||
return `instrument:${instrumentId}`;
|
||||
}
|
||||
|
||||
/** Where a playing of a pattern or a song goes: into one bus, or along its links (#351). */
|
||||
function heardOn(route: MixRoute): Pick<SequencePlay, 'bus' | 'sends'> {
|
||||
return typeof route === 'string' ? { bus: route } : { sends: route };
|
||||
}
|
||||
|
||||
/**
|
||||
* A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or
|
||||
* round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner
|
||||
@@ -390,7 +396,7 @@ function playPattern(
|
||||
loop: loop ? { start: 0, end: length } : null,
|
||||
needs: sequenceNeeds(project, sequence),
|
||||
...(fx && { fx }),
|
||||
bus: playedBus(project, bus, asset.props),
|
||||
...heardOn(playedRoute(project, bus, patternId)),
|
||||
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
|
||||
},
|
||||
owner,
|
||||
@@ -431,7 +437,7 @@ function playSong(
|
||||
fadeIn: heldFade(fadeIn),
|
||||
buses: songBuses(project, song),
|
||||
...(fx && { fx }),
|
||||
bus: playedBus(project, bus, song.props),
|
||||
...heardOn(playedRoute(project, bus, songId)),
|
||||
strike: (note) => strikeNote({ project, audio }, note, owner),
|
||||
strikeClip: (clip) => playClip({ project, audio }, clip),
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ import type { EaseShare } from '../core/model/animation';
|
||||
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
|
||||
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
|
||||
import type { SendTap } from '../core/graph/Graph';
|
||||
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
|
||||
import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
|
||||
import { limiterLatency } from '../core/soundfx/dynamics';
|
||||
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
||||
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||
@@ -632,6 +632,7 @@ export class SoundLibrary implements AudioSink {
|
||||
this.cutPan(bus);
|
||||
bus.pan.connect(this.outOf(target));
|
||||
bus.into = target;
|
||||
this.alignSends();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -714,9 +715,10 @@ export class SoundLibrary implements AudioSink {
|
||||
private async applyMix(): Promise<void> {
|
||||
const names = new Set(this.mix.map((bus) => bus.name));
|
||||
const waits: Promise<void>[] = [];
|
||||
for (const { name, volume, into, fx } of this.mix) {
|
||||
for (const { name, volume, into, sends, fx } of this.mix) {
|
||||
const bus = this.bus(name, into);
|
||||
this.route(name, bus, into);
|
||||
if (sends) this.setSends(bus, sends);
|
||||
else this.route(name, bus, into);
|
||||
this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
|
||||
waits.push(this.setFx(bus, fx));
|
||||
}
|
||||
@@ -943,7 +945,7 @@ export class SoundLibrary implements AudioSink {
|
||||
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
|
||||
const id = this.nextId++;
|
||||
const buses = play.buses ?? [];
|
||||
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX);
|
||||
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
|
||||
const tracks = new Set(buses.map((bus) => bus.name));
|
||||
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
|
||||
const { strike, strikeClip } = play;
|
||||
@@ -992,14 +994,22 @@ export class SoundLibrary implements AudioSink {
|
||||
|
||||
/**
|
||||
* The buses of a playing, its tracks into its master and a pattern item's into its track (#346),
|
||||
* each with the sound FX it was given. Their chains are made once the worklet is there (`ready`).
|
||||
* each with the sound FX it was given; its master into `into`, or along `sends` (#351). Their
|
||||
* chains are made once the worklet is there (`ready`).
|
||||
*/
|
||||
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null, into: string): void {
|
||||
private open(
|
||||
id: number,
|
||||
tracks: readonly BusLevel[],
|
||||
fx: FxVoice | null,
|
||||
into: string,
|
||||
sends?: readonly MixSend[],
|
||||
): void {
|
||||
const masterName = groupBus(id, '');
|
||||
const master = this.bus(masterName, into);
|
||||
master.gain.gain.value = 0;
|
||||
master.set = { gain: 0, pan: 0 };
|
||||
master.fx = fx;
|
||||
if (sends) this.setSends(master, sends);
|
||||
const names = new Set(tracks.map((track) => track.name));
|
||||
const inside = (bus: string | undefined) =>
|
||||
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type SchedulerOptions,
|
||||
} from '../core/music/Scheduler';
|
||||
import { sequenceNeeds } from '../core/music/needs';
|
||||
import { MASTER_MIX } from '../core/music/mixer';
|
||||
import { MASTER_MIX, type MixRoute } from '../core/music/mixer';
|
||||
import type { BusLevel } from '../core/music/playbacks';
|
||||
import type { Sequence } from '../core/music/sequence';
|
||||
import type { Project } from '../core/model/Project';
|
||||
@@ -52,13 +52,13 @@ export class Music {
|
||||
readonly clips: PlannedClip[] = [];
|
||||
|
||||
/**
|
||||
* `output` is the bus of the mix what it plays is heard on (#325): that of the pattern or song in
|
||||
* the tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of
|
||||
* `output` is where what it plays goes (#351): along the routing of the pattern or song in the
|
||||
* tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of
|
||||
* that pattern or song (#352), on the way there.
|
||||
*/
|
||||
constructor(
|
||||
private readonly project: () => Project,
|
||||
private readonly output: () => string = () => MASTER_MIX,
|
||||
private readonly output: () => MixRoute = () => MASTER_MIX,
|
||||
private readonly fx: () => FxVoice | null = () => null,
|
||||
) {}
|
||||
|
||||
@@ -104,12 +104,14 @@ export class Music {
|
||||
|
||||
/**
|
||||
* Where what goes to the output of the pattern or song is heard: its own bus, set to its sound FX
|
||||
* as it is now and on into `output`. A key of the roll is played there too.
|
||||
* as it is now and on along `output`. A key of the roll is played there too.
|
||||
*/
|
||||
heardOn(): string {
|
||||
const fx = this.fx();
|
||||
const output = this.output();
|
||||
const route = typeof output === 'string' ? { into: output } : { sends: output };
|
||||
this.fxReady = soundLibrary.setBuses([
|
||||
{ name: this.outBus, gain: 1, pan: 0, into: this.output(), ...(fx && { fx }) },
|
||||
{ name: this.outBus, gain: 1, pan: 0, ...route, ...(fx && { fx }) },
|
||||
]);
|
||||
return this.outBus;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user