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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 { 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),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'] ?? '');
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) }],
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 { 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));
|
||||||
|
|||||||
@@ -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 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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,7 +56,6 @@ describe('pattern asset (#333)', () => {
|
|||||||
'sign',
|
'sign',
|
||||||
'tempo',
|
'tempo',
|
||||||
'fx',
|
'fx',
|
||||||
'bus',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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(`
|
||||||
|
|||||||
Reference in new issue
Block a user