diff --git a/src/core/commands/routingCommands.ts b/src/core/commands/routingCommands.ts new file mode 100644 index 00000000..1a3b8324 --- /dev/null +++ b/src/core/commands/routingCommands.ts @@ -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); + } +} diff --git a/src/core/commands/songCommands.ts b/src/core/commands/songCommands.ts index 1e474e6a..584eb320 100644 --- a/src/core/commands/songCommands.ts +++ b/src/core/commands/songCommands.ts @@ -6,13 +6,14 @@ */ import type { EdgeSend, Graph } from '../graph/Graph'; import type { Asset, SongAsset } from '../model/Asset'; -import type { Project } from '../model/Project'; +import { findGraphAsset, type Project } from '../model/Project'; import { settleEmbedded } from '../music/embedded'; +import { routedKind, sendsTapped } from '../music/projectRouting'; import { heldSend, heldSongGraph } from '../music/routing'; import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song'; import { heldLoop, type LoopRange } from '../music/sequence'; import { type Removed, removeWhere, restoreAt } from '../util/list'; -import type { Command } from './Command'; +import { type Command, CommandError } from './Command'; import { requireEdge, requireSong } from './require'; export interface SongParts { @@ -75,7 +76,10 @@ export class SetSongCommand implements Command { } } -/** How loud a link of a song's routing sends, and where it taps its track (#350). */ +/** + * How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link + * of the master routing (#351), where only a bus and a song may tap anywhere but after their level. + */ export class SetSendCommand implements Command { readonly label = 'Set send'; private previous: EdgeSend | undefined; @@ -87,10 +91,14 @@ export class SetSendCommand implements Command { ) {} execute(project: Project): void { - requireSong(project, this.assetId); + const holder = findGraphAsset(project, this.assetId); + if (holder?.type !== 'song' && holder?.type !== 'routing') + throw new CommandError(`Routing ${this.assetId} not found`); const edge = requireEdge(project, this.assetId, this.edgeId); + const from = holder.graph.nodes.find((n) => n.id === edge.from.node); + const tapped = holder.type === 'song' || (from !== undefined && sendsTapped(routedKind(from))); this.previous = edge.send; - const held = heldSend(this.send); + const held = heldSend(tapped ? this.send : { ...this.send, tap: 'post' }); if (held) edge.send = held; else delete edge.send; } diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index d818585d..9ff706f8 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -6,6 +6,7 @@ import { isSceneAsset, type SceneAsset, } from './Asset'; +import { createEmptyGraph, type Graph } from '../graph/Graph'; import { DEFAULT_LAYER_NAME } from './collisionLayers'; import { DEFAULT_MASTER_VOLUME } from './master'; import { @@ -60,6 +61,11 @@ export interface Project { */ masterVolume: number; masterFx: string; + /** + * Where the sounds, instruments, patterns, songs and buses go (#351): the graph of the Master + * routing tab. A project written before there was one has every one of them on the master. + */ + routing: Graph; /** * Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project * written before there was one reads as 1, the size the icons always had. @@ -90,6 +96,7 @@ export function createEmptyProject(id: string, name: string): Project { renderLayers: [DEFAULT_LAYER_NAME], masterVolume: DEFAULT_MASTER_VOLUME, masterFx: '', + routing: createEmptyGraph(), symbolScale: DEFAULT_SYMBOL_SCALE, playFrom: DEFAULT_PLAY_FROM, whilePlaying: DEFAULT_WHILE_PLAYING, @@ -122,7 +129,23 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef return asset && isSceneAsset(asset) ? asset : undefined; } -export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined { +/** The id the master routing (#351) goes by where a graph is looked up; no asset id has a `#`. */ +export const ROUTING_ID = '#routing'; + +/** The master routing (#351) where a graph editor and its commands look for an asset's graph. */ +export interface RoutingHolder { + id: typeof ROUTING_ID; + type: 'routing'; + name: string; + graph: Graph; +} + +export type GraphHolder = GraphAsset | RoutingHolder; + +/** The graph asset by its id – or the master routing by `ROUTING_ID`, which is no asset. */ +export function findGraphAsset(project: Project, assetId: string): GraphHolder | undefined { + if (assetId === ROUTING_ID) + return { id: ROUTING_ID, type: 'routing', name: 'Master routing', graph: project.routing }; const asset = findAsset(project, assetId); return asset && isGraphAsset(asset) ? asset : undefined; } diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index ec0ee016..eb03330b 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -22,12 +22,11 @@ import { signOf } from './time'; export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; /** - * The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the - * song's. A pattern keeps its tempo and meter (#363), as one of the project does. + * The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern + * keeps its tempo and meter (#363), as one of the project does. */ const HIDDEN_ROWS: Partial> = { - sound: ['loop', 'bus'], - pattern: ['bus'], + sound: ['loop'], }; export function isEmbeddable(type: AssetType): boolean { diff --git a/src/core/music/mixer.ts b/src/core/music/mixer.ts index 9bddef83..ea61a8f4 100644 --- a/src/core/music/mixer.ts +++ b/src/core/music/mixer.ts @@ -1,20 +1,38 @@ +import type { SendTap } from '../graph/Graph'; import { findAssetOf, type Project } from '../model/Project'; -import type { PropertyBag } from '../model/Properties'; -import { BUS_FX_KEY, busInto, busOf, busVolumeOf, MASTER_BUS } from '../properties/buses'; +import { BUS_FX_KEY, busVolumeOf, MASTER_BUS } from '../properties/buses'; import { coerceAs } from '../properties/kinds'; import type { FxVoice } from '../soundfx/nodes'; import { fxVoiceOf } from './needs'; +import { isRouted } from './projectRouting'; +import { sendOf } from './routing'; +import { dbToGain } from './song'; import { DEFAULT_TEMPO } from './time'; /** What the sound library calls the master bus (#325); every other bus of the mix is `mix:`. */ export const MASTER_MIX = 'mix'; +/** One link of the master routing (#351) as the mix plays it: into that bus, at that level, from that tap. */ +export interface MixSend { + to: string; + gain: number; + tap: SendTap; +} + +/** + * Where something of the project goes (#351): straight into one bus of the mix, as one link at 0 dB + * does, or along its links. No link at all is no send, and silent. + */ +export type MixRoute = string | readonly MixSend[]; + /** One bus of the mix as the sound library sets it up: its level, its sound FX and where it goes. */ export interface MixBus { name: string; volume: number; /** The bus it goes into; null for the master, which is heard. */ into: string | null; + /** Where it goes instead, along the links of the routing (#351); `into` then says nothing. */ + sends?: readonly MixSend[]; fx: FxVoice | null; } @@ -24,29 +42,61 @@ export function mixBusName(busId: string): string { } /** - * The master and every bus of the project (#325), each into the bus it names – or the master, where - * that is missing or would make a loop. A sound FX that does not compile is none. + * Where an asset goes by the master routing (#351). One the routing has no node for goes into the + * master, as every asset of a project written before there was a routing did. A link to what is + * neither a bus nor the master counts for nothing. + */ +export function routeOf(project: Project, assetId: string): MixRoute { + const graph = project.routing; + if (!graph.nodes.some((node) => node.id === assetId)) return MASTER_MIX; + const sends = graph.edges.flatMap((edge) => { + const to = edge.to.node; + if (edge.from.node !== assetId || (to !== MASTER_BUS && !findAssetOf(project, to, 'bus'))) return []; + const { volume, tap } = sendOf(edge); + return [{ to: mixBusName(to), gain: dbToGain(volume), tap }]; + }); + const only = sends.length === 1 ? sends[0]! : null; + return only && only.gain === 1 && only.tap === 'post' ? only.to : sends; +} + +/** + * The master and every bus of the project (#325), each where the routing sends it (#351); and a bus + * of its own for each sound and instrument the routing sends anywhere but straight into one bus, for + * all its voices to go through. A sound FX that does not compile is none. */ export function mixOf(project: Project): MixBus[] { const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO); - const buses = project.assets.flatMap((asset) => - asset.type === 'bus' - ? [ - { - name: mixBusName(asset.id), - volume: busVolumeOf(asset.props), - into: mixBusName(busInto(project, asset.id)), - fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')), - }, - ] - : [], - ); + const routed = (route: MixRoute) => + typeof route === 'string' ? { into: route } : { into: MASTER_MIX, sends: route }; + const buses = project.assets.flatMap((asset): MixBus[] => { + if (asset.type === 'bus') + return [ + { + name: mixBusName(asset.id), + volume: busVolumeOf(asset.props), + ...routed(routeOf(project, asset.id)), + fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')), + }, + ]; + if ((asset.type !== 'sound' && asset.type !== 'instrument') || !isRouted(asset)) return []; + const route = routeOf(project, asset.id); + return typeof route === 'string' + ? [] + : [{ name: mixBusName(asset.id), volume: 1, into: MASTER_MIX, sends: route, fx: null }]; + }); return [ { name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) }, ...buses, ]; } +const sameSends = (a: readonly MixSend[] | undefined, b: readonly MixSend[] | undefined) => + a === b || + (a !== undefined && + b !== undefined && + a.length === b.length && + a.every((send, i) => send.to === b[i]!.to && send.gain === b[i]!.gain && send.tap === b[i]!.tap)); + /** Whether two mixes set the buses up the same: the same plan of a sound FX is the same object. */ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean { return ( @@ -57,6 +107,7 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean { bus.name === other.name && bus.volume === other.volume && bus.into === other.into && + sameSends(bus.sends, other.sends) && bus.fx?.plan === other.fx?.plan && bus.fx?.tempo === other.fx?.tempo ); @@ -65,13 +116,20 @@ export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean { } /** - * The bus of the mix something is heard on: the one a node picked, else the one its asset names. + * The bus of the mix a voice of a sound or an instrument is heard on: the one a node picked, else + * where the routing sends the asset (#351) – its own bus, where that is more than one bus. * A pick naming no bus any more is the master, as a deleted bus would leave it. */ -export function playedBus(project: Project, picked: string, assetProps: PropertyBag): string { - const id = picked !== '' ? picked : busOf(assetProps); - if (id === MASTER_BUS || id === '') return MASTER_MIX; - return findAssetOf(project, id, 'bus') ? mixBusName(id) : MASTER_MIX; +export function playedBus(project: Project, picked: string, assetId: string): string { + const route = playedRoute(project, picked, assetId); + return typeof route === 'string' ? route : mixBusName(assetId); +} + +/** Where a playing of a pattern or a song goes: the bus a node picked, else along its routing (#351). */ +export function playedRoute(project: Project, picked: string, assetId: string): MixRoute { + if (picked === MASTER_BUS) return MASTER_MIX; + if (picked !== '') return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : MASTER_MIX; + return routeOf(project, assetId); } /** The bus of the mix a node reaches where an empty pick is the master: Set bus volume, Bus volume. */ diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index 2685c1af..51ea19c4 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -1,5 +1,6 @@ import type { SendTap } from '../graph/Graph'; import type { FxPlan, FxVoice } from '../soundfx/nodes'; +import type { MixSend } from './mixer'; import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; import type { LoopRange, Sequence } from './sequence'; @@ -47,6 +48,8 @@ export interface SequencePlay { fx?: FxVoice; /** The bus of the mix the whole playing is heard on (#325); the master where left out. */ bus?: string; + /** Where the whole playing goes instead, along the links of the master routing (#351). */ + sends?: readonly MixSend[]; /** Starts one note as the player was asked to, and gives back the number of its voice. */ strike(note: ScheduledNote): number; strikeClip?(clip: ScheduledClip): number; diff --git a/src/core/music/projectRouting.ts b/src/core/music/projectRouting.ts new file mode 100644 index 00000000..9f13bbeb --- /dev/null +++ b/src/core/music/projectRouting.ts @@ -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(); + 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(); + 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(); + const lost = new Set(); + 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[]; renamed: RenamedNode[] }; + edges: { added: GraphEdge[]; removed: Removed[] }; +} + +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, + removedEdges: ReadonlySet, +): 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 { + const problems: Record = {}; + 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; +} diff --git a/src/core/music/routingNodes.ts b/src/core/music/routingNodes.ts index f0057988..405968ee 100644 --- a/src/core/music/routingNodes.ts +++ b/src/core/music/routingNodes.ts @@ -39,3 +39,57 @@ export const SongOutputNode = registerNode({ export function isSongRoutingDef(def: NodeDef): boolean { return def.type === SONG_TRACK || def.type === SONG_OUTPUT; } + +/** + * The nodes of the master routing (#351): one per sound, instrument, pattern, song and bus of the + * project, named and typed after its asset, and the master. Only a bus and the master take links in. + */ +export const ROUTED_ASSET = 'RoutedAsset'; +export const ROUTED_MASTER = 'RoutedMaster'; +export const ROUTED_NAME = 'name'; +export const ROUTED_KIND = 'kind'; + +const KIND_WORDS: Record = { + 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; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 4440a1fb..e67bde58 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -124,7 +124,7 @@ import { } from '../music/song'; import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time'; import { ASSET_FX_KEY } from './assetFx'; -import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses'; +import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses'; import { Theme } from '../../ui'; /** @@ -133,9 +133,7 @@ import { Theme } from '../../ui'; * produces fractions, and the field rounds the value away rather than hiding it. Without * `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick, * `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means - * "from the asset" (#352), and `intoFor` names the bus the row is - * the Into of, which leaves out that bus and every one going into it when the picker opens (#325); - * `uploadedOnly` keeps drawn pictures out + * "from the asset" (#352); `uploadedOnly` keeps drawn pictures out * of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers * instead of its collision layers (#302), with "Inherit" as the first choice of a single layer. */ @@ -149,7 +147,6 @@ type Extras = K extends 'number' none?: string; master?: boolean; noFx?: boolean; - intoFor?: string; } : K extends 'texture' ? { uploadedOnly?: boolean } @@ -485,16 +482,6 @@ const SLICE_SCALE_ROW: PropertyDef = { step: 0.1, }; -/** The bus a sound, an instrument, a pattern or a song is heard on (#325). */ -const BUS_ROW: PropertyDef = { - key: BUS_KEY, - label: 'Bus', - kind: 'asset', - default: '', - assetTypes: ['bus'], - none: 'Master', -}; - /** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */ const FX_ROW: PropertyDef = { key: ASSET_FX_KEY, @@ -706,12 +693,11 @@ const ASSET_SCHEMAS: Record = { { key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 }, { key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false }, FX_ROW, - BUS_ROW, ], // The effects are the asset's graph (see SoundFxAsset), not a property. soundfx: [], // What a note sounds like is the asset's graph (see InstrumentAsset), not a property. - instrument: [FX_ROW, BUS_ROW], + instrument: [FX_ROW], // The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the // tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334). pattern: [ @@ -744,7 +730,6 @@ const ASSET_SCHEMAS: Record = { step: 1, }, FX_ROW, - BUS_ROW, ], // The tracks, items and loop are intrinsic data on the asset (see SongAsset). song: [ @@ -766,13 +751,12 @@ const ASSET_SCHEMAS: Record = { options: SONG_GRID_OPTIONS, }, FX_ROW, - BUS_ROW, ], - // Where sounds are mixed (#325): how loud, through which sound FX, and on into which bus. + // Where sounds are mixed (#325): how loud and through which sound FX. Where it goes on is the + // master routing's (#351). bus: [ { key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 }, { key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] }, - { ...BUS_ROW, key: BUS_INTO_KEY, label: 'Into' }, ], // How many particles and for how long; what one of them does is the asset's graph, whose Property // nodes add their rows after these – see `schemaForBag`. @@ -1121,11 +1105,6 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert // Nothing but declarations: the starting values live in `props` under the very same keys. if (asset.type === 'variables' || asset.type === 'struct') return asset.variables; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; - // A bus never goes into itself, nor into one that already goes into it. - if (asset.type === 'bus') - return FULL_SCHEMAS.bus.map((def) => - def.key === BUS_INTO_KEY && def.kind === 'asset' ? { ...def, intoFor: asset.id } : def, - ); // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. if (asset.type === 'particles') return sourceRows( diff --git a/src/core/properties/buses.ts b/src/core/properties/buses.ts index fb155227..b00364c1 100644 --- a/src/core/properties/buses.ts +++ b/src/core/properties/buses.ts @@ -1,59 +1,18 @@ -import { findAssetOf, type Project } from '../model/Project'; import type { PropertyBag } from '../model/Properties'; import { coerceAs } from './kinds'; -/** The bus a sound, an instrument, a pattern or a song is heard on (#325); empty is the master. */ -export const BUS_KEY = 'bus'; /** How loud a bus is, 0–1. */ export const BUS_VOLUME_KEY = 'volume'; /** The sound FX all of a bus goes through, before its volume. */ export const BUS_FX_KEY = 'fx'; -/** The bus a bus goes into; empty is the master. */ -export const BUS_INTO_KEY = 'into'; /** * What a node picks to name the master where empty means something else: "from the asset" on a - * Play node, "no bus" on Set sound FX parameters. No asset id has a `#`. + * Play node, "no bus" on Set sound FX parameters. No asset id has a `#`. It is also the id of the + * master in the master routing (#351). */ export const MASTER_BUS = '#master'; -export function busOf(bag: PropertyBag): string { - return coerceAs(bag[BUS_KEY], 'asset', ''); -} - export function busVolumeOf(bag: PropertyBag): number { return Math.max(0, Math.min(1, coerceAs(bag[BUS_VOLUME_KEY], 'number', 1))); } - -/** The bus a bus says it goes into, where that is a bus; '' for the master. */ -function namedInto(project: Project, busId: string): string { - const bus = findAssetOf(project, busId, 'bus'); - const into = bus ? coerceAs(bus.props[BUS_INTO_KEY], 'asset', '') : ''; - return into !== '' && findAssetOf(project, into, 'bus') ? into : ''; -} - -/** Whether following the buses on from `from` comes to `to`. */ -function reaches(project: Project, from: string, to: string): boolean { - const seen = new Set(); - 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); -} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index f87fcb29..93213eae 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -25,7 +25,8 @@ import { heldPatternNotes } from '../music/pattern'; import { isEmbeddable } from '../music/embedded'; import { heldSongItems, heldSongTracks } from '../music/song'; import { heldSend, heldSongGraph } from '../music/routing'; -import { isSongRoutingDef } from '../music/routingNodes'; +import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes'; +import { heldRouting } from '../music/projectRouting'; import { heldLoop, type LoopRange } from '../music/sequence'; import { isKeyInterpolation, normalizeKeys } from '../model/animation'; import type { @@ -340,6 +341,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { renderLayers: renderLayersOf(r), masterVolume: masterVolumeOf(r), masterFx: masterFxOf(r), + routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false), symbolScale: symbolScaleOf(r), playFrom: playFromOf(r), whilePlaying: whilePlayingOf(r), diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 16ef4c29..109738db 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1,6 +1,6 @@ import type { Application, Ticker } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; -import { findAsset, findObjectHolder } from '../core/model/Project'; +import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project'; import { assetTitle } from '../core/music/embedded'; import { mixOf } from '../core/music/mixer'; import type { Role } from '../core/store/AuthStore'; @@ -94,6 +94,7 @@ import type { PluginManager } from './plugins/loader'; import { PluginsPane } from './plugins/PluginsPane'; import { ManagementPane } from './users/ManagementPane'; import { UsersPane } from './users/UsersPane'; +import { RoutingEditor } from './routing/RoutingEditor'; export type CentreTab = | 'scene' @@ -115,6 +116,7 @@ export type CentreTab = | 'tile' | 'container' | 'variables' + | 'routing' | 'management'; /** A tab a plugin added; the prefix keeps it apart from every built-in one. */ @@ -123,10 +125,10 @@ export type PluginTabId = `plugin:${string}`; export type TabId = CentreTab | PluginTabId; /** * An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its - * `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it - * opens and closes like one. + * `valuesKey` – as many of those as were opened. The user management (#179) and the master routing + * (#351) are no documents, but they open and close like one. */ -export type DocumentId = `asset:${string}` | `values:${string}` | 'management'; +export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management'; /** The account behind this session; the menu for it sits at the far right of the toolbar. */ export interface EditorAccount { /** Who is signed in – the menu says so, since the toolbar has no room for a second label. */ @@ -144,7 +146,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId; type FixedTab = (typeof FIXED_TABS)[number]; /** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */ -type AssetKind = Exclude; +type AssetKind = Exclude; const FIXED_TABS = ['scene', 'game'] as const; @@ -168,6 +170,7 @@ const PANE_ORDER = [ 'tile', 'container', 'variables', + 'routing', 'management', ] as const; @@ -192,6 +195,7 @@ const TAB_LABELS: Record = { tile: 'Tile', container: 'Container', variables: 'Variables', + routing: 'Master routing', management: 'User management', }; @@ -202,7 +206,7 @@ const FIXED_TAB_ICONS: Partial> = { }; function isDocument(tab: StripTab): tab is DocumentId { - return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:'); + return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:'); } const MIN_LEFT_WIDTH = 160; @@ -245,6 +249,7 @@ export class Editor implements PlayHost { readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; readonly variablesEditor: VariablesEditor; + readonly routingEditor: RoutingEditor; /** What each open Variables tab shows, by its document. */ private readonly valueTargets = new Map(); /** The switch per file in `data/plugins/`, in the user management tab (#179). */ @@ -335,6 +340,7 @@ export class Editor implements PlayHost { this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); this.variablesEditor = new VariablesEditor(state); + this.routingEditor = new RoutingEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); @@ -383,7 +389,10 @@ export class Editor implements PlayHost { this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane); this.toolbar = new Toolbar(state, { - openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()), + openMainMenu: (anchor) => + this.projects.openMainMenu(anchor, this.managementEntries(), [ + { label: 'Master routing', onSelect: () => this.openDocument('routing') }, + ]), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePause: () => this.play.togglePause(), openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), @@ -453,6 +462,7 @@ export class Editor implements PlayHost { game: this.gamePane, ...this.assetEditors, variables: this.variablesEditor, + routing: this.routingEditor, management: this.managementPane, }; // Following a call goes on in the same tab: the tab changes its document, not its place. @@ -532,7 +542,8 @@ export class Editor implements PlayHost { // creating it) is for – see the `open` event below. state.on('selection', () => { const s = state.selection; - if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song') + if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing'); + else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song') this.openDocument(`asset:${s.assetId}`); if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container') this.openDocument(`asset:${s.holderId}`); @@ -791,6 +802,7 @@ export class Editor implements PlayHost { if (kind && kind in this.assetEditors) this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.valueTargets.delete(doc); + if (doc === 'routing') this.routingEditor.close(); if (!wasShowing) return; if (this.documents.active) this.showDocument(this.documents.active); else this.showTab('scene'); @@ -807,8 +819,8 @@ export class Editor implements PlayHost { if (doc) this.openDocument(doc); return; } - if (tab === 'management') { - this.openDocument('management'); + if (tab === 'management' || tab === 'routing') { + this.openDocument(tab); return; } if (tab in this.assetEditors) { @@ -856,7 +868,7 @@ export class Editor implements PlayHost { const target = this.valueTargets.get(doc); return target && valuesTitle(this.state, target) !== null ? 'variables' : null; } - if (doc === 'management') return 'management'; + if (doc === 'management' || doc === 'routing') return doc; const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; const kind = type ? TAB_OF_ASSET[type] : undefined; return kind && kind !== 'scene' ? kind : null; @@ -870,12 +882,14 @@ export class Editor implements PlayHost { if (kind === 'variables' && target) this.variablesEditor.show(target); // Read on every showing: an account may have been changed from another tab in the meantime. if (kind === 'management') void this.usersPane?.reload(); + if (kind === 'routing') this.routingEditor.open(); this.display(kind, doc); } /** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ private labelOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; + if (doc === 'routing') return TAB_LABELS.routing; const target = this.valueTargets.get(doc); if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; const asset = findAsset(this.state.project, doc.slice('asset:'.length)); @@ -885,6 +899,7 @@ export class Editor implements PlayHost { /** The icon in front of a document's name: its asset type's, as in the asset panel. */ private iconOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG; + if (doc === 'routing') return ASSET_ICON_SVG.bus; const target = this.valueTargets.get(doc); if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables; const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; @@ -900,7 +915,7 @@ export class Editor implements PlayHost { const kind = this.kindOf(doc); if (!this.tabs.has(doc) && kind) this.tabs.addTab(doc, this.labelOf(doc), base + index, { - kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind], + kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind], icon: this.iconOf(doc), onClose: () => this.closeDocument(doc), }); diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index d1d964ac..9600a68f 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -105,9 +105,10 @@ export class ProjectSession { /** * The one menu of the top bar (#183): everything that is about the project rather than a scene. - * `more` comes last – what the editor adds that is not about the project (#179). + * `project` is what the editor adds about it, after its settings (#351); `more` comes last – what + * the editor adds that is not about the project (#179). */ - openMainMenu(anchor: Widget, more: MenuEntry[] = []): void { + openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void { const items: MenuEntry[] = [ { label: 'New project', onSelect: () => void this.run(this.createNext()) }, { label: 'Open project…', onSelect: () => this.openProjectPicker() }, @@ -115,6 +116,7 @@ export class ProjectSession { { label: 'Snapshots…', onSelect: () => this.openSnapshots() }, { label: 'Release…', onSelect: () => this.openRelease() }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, + ...project, { label: 'Clear saved data', onSelect: () => this.clearSavedData() }, ...more, ]; diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index fea6e476..c17b2862 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -13,12 +13,17 @@ import { namesBehavior, SHADER_CALL, } from '../core/graph'; -import type { GraphAsset } from '../core/model/Asset'; import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; import { findObject } from '../core/model/hierarchy'; import { isLiveInstance } from '../core/model/instance'; import type { Vec2 } from '../core/model/Properties'; -import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project'; +import { + findGraphAsset, + findObjectHolder, + type GraphHolder, + type ObjectHolder, + type Project, +} from '../core/model/Project'; import { GRID_STEP } from './graph/graphView'; import { paletteEntries } from './graph/paletteEntries'; import { type EditorState, graphSelection, objectSelection } from './state'; @@ -158,7 +163,7 @@ export function pasteObjects( } /** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */ -function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean { +function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean { return paletteEntries(project, asset).some( (entry) => entry.def.type === node.type && @@ -170,7 +175,7 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea /** The part of a graph copy `asset` can take, and the nodes it cannot. */ function fittingPart( project: Project, - asset: GraphAsset, + asset: GraphHolder, clip: GraphClip, ): { clip: GraphClip; skipped: GraphNode[] } { const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n)); diff --git a/src/editor/debug/routingHooks.ts b/src/editor/debug/routingHooks.ts new file mode 100644 index 00000000..3238cd84 --- /dev/null +++ b/src/editor/debug/routingHooks.ts @@ -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(), + }, + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d5b3c065..c0a2e046 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -16,6 +16,7 @@ import { saturationAutoGain } from '../core/soundfx/saturation'; import { curveHooks } from './debug/curveHooks'; import { soundHooks } from './debug/soundHooks'; import { patternHooks } from './debug/patternHooks'; +import { routingHooks } from './debug/routingHooks'; import { songHooks } from './debug/songHooks'; import { documentHooks } from './debug/documentHooks'; import { graphHooks } from './debug/graphHooks'; @@ -50,6 +51,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E ...curveHooks(context), ...patternHooks(context), ...songHooks(context), + ...routingHooks(context), ...soundHooks(context), ...renderHooks(context), /** diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 8aaa5fd8..371cda95 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -26,14 +26,16 @@ import { soundFxParamsMismatch, startStateOf, } from '../../core/graph'; -import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { soundFxBuildFor } from '../../core/soundfx/compile'; import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile'; +import { masterRoutingProblems } from '../../core/music/projectRouting'; import { routingProblems } from '../../core/music/routing'; -import { findAsset, findGraphAsset } from '../../core/model/Project'; +import { ROUTED_ASSET } from '../../core/music/routingNodes'; +import { hasAssetEditor } from '../assetTabs'; +import { findAsset, findGraphAsset, type GraphHolder } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { ViewTween } from '../viewTween'; import { BeadClock } from './beads'; @@ -281,7 +283,7 @@ export class GraphCanvas extends Widget { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } - private asset(): GraphAsset | null { + private asset(): GraphHolder | null { return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null; } @@ -316,7 +318,9 @@ export class GraphCanvas extends Widget { ? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems : asset.type === 'song' ? routingProblems(asset.graph) - : null; + : asset.type === 'routing' + ? masterRoutingProblems(asset.graph) + : null; for (const node of asset.graph.nodes) { const resolved = resolveDef(node, asset.graph, lookup); if (!resolved) continue; @@ -429,7 +433,7 @@ export class GraphCanvas extends Widget { * one held by the selected object's instance. Without a selection nothing is strong – that keeps * "many instances, many states" readable instead of picking one of them at random. */ - private stateMarks(asset: GraphAsset): Map { + private stateMarks(asset: GraphHolder): Map { const marks = new Map(); if (asset.type !== 'statemachine') return marks; const selection = this.state.selection; @@ -665,6 +669,13 @@ export class GraphCanvas extends Widget { } private openCalledBehavior(node: GraphNode): boolean { + // A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already. + if (node.type === ROUTED_ASSET) { + const asset = findAsset(this.state.project, node.id); + if (!asset || !hasAssetEditor(asset.type)) return false; + this.state.openAsset(node.id); + return true; + } if (node.type === SHADER_CALL || node.type === INSTRUMENT_SOUND_FX) { const shader = node.type === SHADER_CALL; const id = textOf(node.params[shader ? FUNCTION_PARAM : 'fx'] ?? ''); diff --git a/src/editor/graph/graphHost.ts b/src/editor/graph/graphHost.ts index 849175bc..092e803f 100644 --- a/src/editor/graph/graphHost.ts +++ b/src/editor/graph/graphHost.ts @@ -1,5 +1,5 @@ import type { AddNodeCommand } from '../../core/commands/graphCommands'; -import type { GraphAsset } from '../../core/model/Asset'; +import type { GraphHolder } from '../../core/model/Project'; import type { Box } from '../framing'; import type { EditorState } from '../state'; import type { NodeView } from './NodeView'; @@ -11,7 +11,7 @@ export interface GraphCanvasHost { readonly state: EditorState; /** The asset on the canvas; empty when there is none. */ assetId(): string; - asset(): GraphAsset | null; + asset(): GraphHolder | null; views(): ReadonlyMap; /** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */ topDownViews(): Iterable; diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 2b065156..46409fcf 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -21,11 +21,11 @@ import { STATE_NAME_PARAM, terminalNode, } from '../../core/graph'; -import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset'; +import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset'; import { FIND, FIND_MODE } from '../../core/graph/findBlock'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; -import type { Project } from '../../core/model/Project'; +import type { GraphHolder, Project } from '../../core/model/Project'; import { isParticlePropertyNode, PARTICLE_PROPERTY, @@ -132,14 +132,14 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse * generic "Run behavior" – and once more as the event "On " if it has an End to be heard * through (#294) –, and the two terminals drop out once the graph has them. */ -export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] { +export function paletteEntries(project: Project, asset: GraphHolder): PaletteEntry[] { if (asset.type === 'statemachine') { const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) }; return [{ id: State.type, hint: State.category, def: State, params }]; } if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset); - // A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350). - if (asset.type === 'song') return []; + // A routing holds nodes that come and go on their own: a song's tracks (#350), the assets (#351). + if (asset.type === 'song' || asset.type === 'routing') return []; if (asset.type === 'soundfx') { const placed = new Set(asset.graph.nodes.map((n) => n.type)); return SOUND_FX_NODES.filter( diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 55999ae7..0f5a64f6 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -17,7 +17,9 @@ import { objectSignature, refreshObjectSection, } from './objectSection'; +import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection'; import { buildSongSection, refreshSongSection, songSignature } from './songSection'; +import { ROUTING_ID } from '../../core/model/Project'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; import { INLINE_LIMIT } from '../variables/target'; import { inlineListsIn } from '../variables/VariablesEditor'; @@ -130,6 +132,7 @@ export class Inspector extends Panel { new Label('Select an asset, a scene object or a node.', { style: 'muted', wrap: true }), ); else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); + else if (s.kind === 'node' && s.assetId === ROUTING_ID) buildRoutingNodeSection(this.host, s.nodeId); else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId); @@ -147,6 +150,8 @@ export class Inspector extends Panel { const s = this.state.selection; if (!s) return ''; if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); + if (s.kind === 'node' && s.assetId === ROUTING_ID) + return routingNodeSignature(this.state.project, s.nodeId); if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId); @@ -176,23 +181,25 @@ export class Inspector extends Panel { const ok = s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId) - : s.kind === 'node' - ? refreshNodeSection(this.host, s.assetId, s.nodeId) - : s.kind === 'frame' - ? refreshFrameSection(this.host, s.assetId, s.frameId) - : s.kind === 'clip' - ? refreshClipSection(this.host, s.assetId, s.clipId) - : s.kind === 'shape' - ? refreshShapeSection(this.host, s.assetId, s.shapeId) - : s.kind === 'annotation' - ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) - : s.kind === 'track' - ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) - : s.kind === 'edge' - ? refreshEdgeSection(this.host, s.assetId, s.edgeId) - : s.kind === 'song' - ? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'node' && s.assetId === ROUTING_ID + ? refreshRoutingNodeSection(this.host, s.nodeId) + : s.kind === 'node' + ? refreshNodeSection(this.host, s.assetId, s.nodeId) + : s.kind === 'frame' + ? refreshFrameSection(this.host, s.assetId, s.frameId) + : s.kind === 'clip' + ? refreshClipSection(this.host, s.assetId, s.clipId) + : s.kind === 'shape' + ? refreshShapeSection(this.host, s.assetId, s.shapeId) + : s.kind === 'annotation' + ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) + : s.kind === 'track' + ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) + : s.kind === 'edge' + ? refreshEdgeSection(this.host, s.assetId, s.edgeId) + : s.kind === 'song' + ? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/edgeSection.ts b/src/editor/inspector/edgeSection.ts index d5676939..ed4cf5ff 100644 --- a/src/editor/inspector/edgeSection.ts +++ b/src/editor/inspector/edgeSection.ts @@ -3,11 +3,13 @@ import { SetSendCommand } from '../../core/commands/songCommands'; import { type EdgeSend, findNode, + type Graph, type GraphEdge, isStateNode, resolveDef, stateName, } from '../../core/graph'; +import { routedKind, sendsTapped } from '../../core/music/projectRouting'; import { SEND_TAPS, sendOf } from '../../core/music/routing'; import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../core/music/song'; import type { PropertyDef } from '../../core/properties/PropertySchema'; @@ -47,15 +49,18 @@ export interface EdgeDescription { /** What flows through it, as the output names it. */ type: string; name: string; - /** On a link of a song's routing (#350): what it sends; null on any other link. */ + /** On a link of a song's routing (#350) or of the master routing (#351): what it sends; null on any other link. */ send: EdgeSend | null; + /** Whether the link may take the sound anywhere but after the level: not out of a sound, an instrument or a pattern. */ + tapped: boolean; } /** Null when the link is gone. An end whose node or port is gone reads `?`. */ export function describeEdge(project: Project, assetId: string, edgeId: string): EdgeDescription | null { - const graph = findGraphAsset(project, assetId)?.graph; + const holder = findGraphAsset(project, assetId); + const graph = holder?.graph; const edge = graph?.edges.find((e) => e.id === edgeId); - if (!graph || !edge) return null; + if (!holder || !graph || !edge) return null; const lookup = graphLookup(project); const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => { const node = findNode(graph, side.node); @@ -78,10 +83,17 @@ export function describeEdge(project: Project, assetId: string, edgeId: string): input: to.port, type: from.type, name: edge.label ?? '', - send: findGraphAsset(project, assetId)?.type === 'song' ? sendOf(edge) : null, + send: holder.type === 'song' || holder.type === 'routing' ? sendOf(edge) : null, + tapped: holder.type === 'song' || sendsTapped(fromKind(graph, edge)), }; } +/** The asset type a link of the master routing (#351) comes out of; `''` for anything else. */ +function fromKind(graph: Graph, edge: GraphEdge): string { + const node = findNode(graph, edge.from.node); + return node ? routedKind(node) : ''; +} + /** Structure of a link selection: how many, and for one of them what it joins. */ export function edgeSignature( project: Project, @@ -140,15 +152,19 @@ export function buildEdgeSection( onInput: () => {}, onChange: (next) => set({ volume: Number(next) }), }); + host.fields.set(SEND_VOLUME.key, volume); + host.body.add(volume.row); + if (!d.tapped) { + addNote(host, 'In decibels, after the volume and the sound FX of the asset.', 'small'); + return; + } const tap = createFieldRow(host, SEND_TAP, send.tap, { onInput: () => {}, onChange: (next) => set({ tap: SEND_TAPS.find((t) => t.value === textOf(next))?.value ?? 'post' }), }); - host.fields.set(SEND_VOLUME.key, volume); host.fields.set(SEND_TAP.key, tap); - host.body.add(volume.row); host.body.add(tap.row); - addNote(host, 'In decibels. Mode says where on its track the link takes the sound.', 'small'); + addNote(host, 'In decibels. Mode says where on its track or bus the link takes the sound.', 'small'); } else { fact('Input', d.input); fact('Type', d.type); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index bab336ad..58e9b002 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -20,7 +20,7 @@ import { soundEntries, } from '../../core/properties/assetRef'; import { NO_FX } from '../../core/properties/assetFx'; -import { busesInto, MASTER_BUS } from '../../core/properties/buses'; +import { MASTER_BUS } from '../../core/properties/buses'; import { keyName } from '../../core/keys'; import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs'; @@ -403,19 +403,15 @@ const REFERENCES: Record = { label: assetRefLabel, choices: (project, def) => { const extras = def.kind === 'asset' ? def : undefined; - // Asked as the menu opens: another bus may have been sent into this one meanwhile. - const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []); return [ ...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []), ...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []), - ...assetRefChoices(project, extras?.assetTypes) - .filter((asset) => !left.has(asset.id)) - .flatMap((asset) => - // A sound with its clips (#357), each as "Kick / Clip 1 (sound)". - asset.type === 'sound' - ? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` })) - : [{ id: asset.id, label: assetChoiceLabel(asset) }], - ), + ...assetRefChoices(project, extras?.assetTypes).flatMap((asset) => + // A sound with its clips (#357), each as "Kick / Clip 1 (sound)". + asset.type === 'sound' + ? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` })) + : [{ id: asset.id, label: assetChoiceLabel(asset) }], + ), ]; }, }, diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index b0fc40a9..414df59a 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -26,9 +26,8 @@ import { TRANSITION_PARAM, variablesSourceOf, } from '../../core/graph'; -import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; -import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; +import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { isListNode, @@ -106,7 +105,7 @@ function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string { } /** A state machine declares no variables; its one node type has none to name. */ -const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []); +const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []); /** Description, settings (params) and the inline values of unconnected data inputs. */ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void { diff --git a/src/editor/inspector/routingSection.ts b/src/editor/inspector/routingSection.ts new file mode 100644 index 00000000..d0226102 --- /dev/null +++ b/src/editor/inspector/routingSection.ts @@ -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; +} diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts index 8d66752d..88b6ece6 100644 --- a/src/editor/pattern/PatternEditor.ts +++ b/src/editor/pattern/PatternEditor.ts @@ -24,7 +24,7 @@ import { DEFAULT_TEMPO } from '../../core/music/time'; import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext'; import { Button, Dropdown, Label, Menu, type Widget } from '../../ui'; import { Music } from '../../viewport/music'; -import { playedBus } from '../../core/music/mixer'; +import { playedRoute } from '../../core/music/mixer'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; import { claimTransport, releaseTransport } from '../music/transport'; @@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor { readonly ghostsButton: Button; /** Where the ghost notes come from: song, track and bar. */ readonly ghostSource = new Label('', { style: 'muted' }); - /** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */ + /** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */ readonly music = new Music( () => this.state.project, - () => playedBus(this.state.project, '', this.asset()?.props ?? {}), + () => playedRoute(this.state.project, '', this.asset()?.id ?? ''), () => { const asset = this.asset(); return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props)); diff --git a/src/editor/routing/RoutingEditor.ts b/src/editor/routing/RoutingEditor.ts new file mode 100644 index 00000000..110ee65f --- /dev/null +++ b/src/editor/routing/RoutingEditor.ts @@ -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); + } +} diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index fda1d6dd..41d1e643 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -27,7 +27,7 @@ import { assetFxOf } from '../../core/properties/assetFx'; import type { SoundStore } from '../../core/store/SoundStore'; import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui'; import { Music } from '../../viewport/music'; -import { playedBus } from '../../core/music/mixer'; +import { playedRoute } from '../../core/music/mixer'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { type InputDevice, @@ -92,10 +92,10 @@ export class SongEditor extends AssetTabEditor { readonly snap = new SnapToggle(() => this.routing); private shown: SongView = 'timeline'; private readonly pane = new VStack(); - /** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */ + /** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */ readonly music = new Music( () => this.state.project, - () => playedBus(this.state.project, '', this.asset()?.props ?? {}), + () => playedRoute(this.state.project, '', this.asset()?.id ?? ''), () => { const asset = this.asset(); return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props)); diff --git a/src/editor/sound/SoundEditor.ts b/src/editor/sound/SoundEditor.ts index 53c58f3f..9b82d147 100644 --- a/src/editor/sound/SoundEditor.ts +++ b/src/editor/sound/SoundEditor.ts @@ -152,8 +152,8 @@ export class SoundEditor extends AssetTabEditor { this.stop(); claimTransport(this.halt); const props = resolveAssetProps(this.state.project, asset); - // Through its sound FX (#352), its bus and the master, as the game hears it (#325). - const bus = playedBus(this.state.project, '', props); + // Through its sound FX (#352) and along its routing (#351), as the game hears it. + const bus = playedBus(this.state.project, '', asset.id); const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE; const file = playedSoundOf({ asset, clip }); this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index ee31125a..9cc4aa0a 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -106,8 +106,8 @@ export class SoundFxEditor extends AssetTabEditor { if (!sound?.asset.file || !plan) return; const props = resolveAssetProps(this.state.project, sound.asset); const fx = { plan, params: { ...this.values } }; - // On the bus of the sound, as the game would play it there (#325). - const bus = playedBus(this.state.project, '', props); + // Along the routing of the sound, as the game would play it there (#351). + const bus = playedBus(this.state.project, '', sound.asset.id); const file = playedSoundOf(sound); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); if (voice) this.voices.push(voice); diff --git a/src/editor/state.ts b/src/editor/state.ts index c9de12af..abacf7bc 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -28,6 +28,8 @@ import type { GameScale, PlayFrom } from '../core/model/playSettings'; import type { PlaceableAssetType } from '../core/model/AssetTypes'; import type { VisibilityPart } from '../core/model/sceneVisibility'; import type { ValuePath } from '../core/properties/valuePath'; +import { KeepRoutingCommand } from '../core/commands/routingCommands'; +import { settleRouting } from '../core/music/projectRouting'; export type Selection = | { kind: 'asset'; assetId: string } @@ -310,6 +312,8 @@ export class EditorState { this.paused = false; this.project = project; this.revision = revision; + // Every sound, instrument, pattern, song and bus has its node in the Master routing (#351). + settleRouting(project); this.commands = new CommandStack(project); this.selection = null; this.openedValues = null; @@ -409,7 +413,7 @@ export class EditorState { */ execute(command: Command): boolean { return this.runEdit(() => { - this.commands.execute(command); + this.commands.execute(new KeepRoutingCommand(command)); return true; }); } diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 3f21f7e8..6a9d0419 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -31,8 +31,9 @@ import { songTempo, SOUND_ITEM_MAX_DB, } from '../../core/music/song'; +import type { SequencePlay } from '../../core/music/playbacks'; import { songBuses } from '../../core/music/songBuses'; -import { playedBus, reachedBus } from '../../core/music/mixer'; +import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer'; import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; @@ -295,7 +296,7 @@ function playSound( loop ?? loopsOf(props), owner, fx ?? DRY_VOICE, - playedBus(project, bus, props), + playedBus(project, bus, ref.asset.id), ); } @@ -306,7 +307,7 @@ export function instrumentFx(project: Project, instrumentId: string, picked: str /** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */ export function instrumentBus(project: Project, instrumentId: string, picked: string): string { - return playedBus(project, picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {}); + return playedBus(project, picked, instrumentId); } /** @@ -357,6 +358,11 @@ export function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; } +/** Where a playing of a pattern or a song goes: into one bus, or along its links (#351). */ +function heardOn(route: MixRoute): Pick { + return typeof route === 'string' ? { bus: route } : { sends: route }; +} + /** * A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or * round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner @@ -390,7 +396,7 @@ function playPattern( loop: loop ? { start: 0, end: length } : null, needs: sequenceNeeds(project, sequence), ...(fx && { fx }), - bus: playedBus(project, bus, asset.props), + ...heardOn(playedRoute(project, bus, patternId)), strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), }, owner, @@ -431,7 +437,7 @@ function playSong( fadeIn: heldFade(fadeIn), buses: songBuses(project, song), ...(fx && { fx }), - bus: playedBus(project, bus, song.props), + ...heardOn(playedRoute(project, bus, songId)), strike: (note) => strikeNote({ project, audio }, note, owner), strikeClip: (clip) => playClip({ project, audio }, clip), }, diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index a5584691..a7d80d26 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -35,7 +35,7 @@ import type { EaseShare } from '../core/model/animation'; import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks'; import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing'; import type { SendTap } from '../core/graph/Graph'; -import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer'; +import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer'; import { limiterLatency } from '../core/soundfx/dynamics'; import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler'; import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song'; @@ -632,6 +632,7 @@ export class SoundLibrary implements AudioSink { this.cutPan(bus); bus.pan.connect(this.outOf(target)); bus.into = target; + this.alignSends(); } /** @@ -714,9 +715,10 @@ export class SoundLibrary implements AudioSink { private async applyMix(): Promise { const names = new Set(this.mix.map((bus) => bus.name)); const waits: Promise[] = []; - for (const { name, volume, into, fx } of this.mix) { + for (const { name, volume, into, sends, fx } of this.mix) { const bus = this.bus(name, into); - this.route(name, bus, into); + if (sends) this.setSends(bus, sends); + else this.route(name, bus, into); this.glideLevel(bus, this.mixVolumes.get(name) ?? volume); waits.push(this.setFx(bus, fx)); } @@ -943,7 +945,7 @@ export class SoundLibrary implements AudioSink { if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0; const id = this.nextId++; const buses = play.buses ?? []; - this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX); + this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends); const tracks = new Set(buses.map((bus) => bus.name)); const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : ''); const { strike, strikeClip } = play; @@ -992,14 +994,22 @@ export class SoundLibrary implements AudioSink { /** * The buses of a playing, its tracks into its master and a pattern item's into its track (#346), - * each with the sound FX it was given. Their chains are made once the worklet is there (`ready`). + * each with the sound FX it was given; its master into `into`, or along `sends` (#351). Their + * chains are made once the worklet is there (`ready`). */ - private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null, into: string): void { + private open( + id: number, + tracks: readonly BusLevel[], + fx: FxVoice | null, + into: string, + sends?: readonly MixSend[], + ): void { const masterName = groupBus(id, ''); const master = this.bus(masterName, into); master.gain.gain.value = 0; master.set = { gain: 0, pan: 0 }; master.fx = fx; + if (sends) this.setSends(master, sends); const names = new Set(tracks.map((track) => track.name)); const inside = (bus: string | undefined) => bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName; diff --git a/src/viewport/music.ts b/src/viewport/music.ts index 177a6f1c..128100ff 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -5,7 +5,7 @@ import { type SchedulerOptions, } from '../core/music/Scheduler'; import { sequenceNeeds } from '../core/music/needs'; -import { MASTER_MIX } from '../core/music/mixer'; +import { MASTER_MIX, type MixRoute } from '../core/music/mixer'; import type { BusLevel } from '../core/music/playbacks'; import type { Sequence } from '../core/music/sequence'; import type { Project } from '../core/model/Project'; @@ -52,13 +52,13 @@ export class Music { readonly clips: PlannedClip[] = []; /** - * `output` is the bus of the mix what it plays is heard on (#325): that of the pattern or song in - * the tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of + * `output` is where what it plays goes (#351): along the routing of the pattern or song in the + * tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of * that pattern or song (#352), on the way there. */ constructor( private readonly project: () => Project, - private readonly output: () => string = () => MASTER_MIX, + private readonly output: () => MixRoute = () => MASTER_MIX, private readonly fx: () => FxVoice | null = () => null, ) {} @@ -104,12 +104,14 @@ export class Music { /** * Where what goes to the output of the pattern or song is heard: its own bus, set to its sound FX - * as it is now and on into `output`. A key of the roll is played there too. + * as it is now and on along `output`. A key of the roll is played there too. */ heardOn(): string { const fx = this.fx(); + const output = this.output(); + const route = typeof output === 'string' ? { into: output } : { sends: output }; this.fxReady = soundLibrary.setBuses([ - { name: this.outBus, gain: 1, pan: 0, into: this.output(), ...(fx && { fx }) }, + { name: this.outBus, gain: 1, pan: 0, ...route, ...(fx && { fx }) }, ]); return this.outBus; } diff --git a/tests/core/asset-fx.test.ts b/tests/core/asset-fx.test.ts index 9e2cd45b..0317fb77 100644 --- a/tests/core/asset-fx.test.ts +++ b/tests/core/asset-fx.test.ts @@ -128,10 +128,10 @@ function gains(layer: FxPlan | undefined): number[] { } describe('the Sound FX row (#352)', () => { - it('stands right before Bus on a sound, an instrument, a pattern and a song', () => { + it('stands last on a sound, an instrument, a pattern and a song, where Bus stood before #351', () => { for (const type of ['sound', 'instrument', 'pattern', 'song'] as AssetType[]) { const keys = schemaFor(type).map((def) => def.key); - expect(keys.indexOf(ASSET_FX_KEY), type).toBe(keys.indexOf('bus') - 1); + expect(keys.indexOf(ASSET_FX_KEY), type).toBe(keys.length - 1); expect(schemaFor(type).find((def) => def.key === ASSET_FX_KEY)).toMatchObject({ kind: 'asset', default: '', diff --git a/tests/core/buses.test.ts b/tests/core/buses.test.ts index 71eb1175..7d7708bc 100644 --- a/tests/core/buses.test.ts +++ b/tests/core/buses.test.ts @@ -1,6 +1,6 @@ /** - * Master and buses (#325): which bus a bus goes into, what the sound library is set up with, and - * which bus a sound, a note, a pattern or a song is heard on. + * Master and buses (#325): what the sound library is set up with, and which bus a sound, a note, a + * pattern or a song is heard on – where each goes is the master routing's (#351). */ import { describe, expect, it } from 'vitest'; import { @@ -21,7 +21,7 @@ import { reachedBus, sameMix, } from '../../src/core/music/mixer'; -import { BUS_INTO_KEY, BUS_KEY, busesInto, busInto, MASTER_BUS } from '../../src/core/properties/buses'; +import { MASTER_BUS } from '../../src/core/properties/buses'; import { schemaForAsset } from '../../src/core/properties/PropertySchema'; import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes'; import { GameRun } from '../../src/runtime/GameRun'; @@ -34,6 +34,7 @@ import { makeSpriteScene, place, roundTrip, + routingOf, startWorld, } from '../helpers'; @@ -46,9 +47,8 @@ function withBuses(...names: string[]) { f.stack.execute(command); return command.asset.id; }); - const into = (bus: string, target: string) => - f.stack.execute(new SetAssetPropertyCommand(bus, BUS_INTO_KEY, target)); - return { ...f, ids, into }; + const routing = routingOf(f); + return { ...f, ids, into: (bus: string, target: string) => routing.only(bus, target) }; } /** A sound FX that compiles: Input, a Gain, Output. */ @@ -65,56 +65,23 @@ function workingFx(f: ReturnType): string { } describe('the bus asset (#325)', () => { - it('is not placeable and starts at full volume, without a sound FX, into the master', () => { + it('is not placeable and starts at full volume, without a sound FX', () => { expect(isPlaceableType('bus')).toBe(false); const f = withBuses('Music'); const bus = f.project.assets[0]!; - expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '', into: '' } }); - expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx', 'into']); + expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '' } }); + expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx']); expect(roundTrip(f.project)).toEqual(f.project); }); - it('names the master as the empty pick of Into and of the Bus of a sound, an instrument, a pattern and a song', () => { + it('leaves where a sound, an instrument, a pattern, a song and a bus go to the routing (#351)', () => { const f = withBuses('Music'); - const into = schemaForAsset(f.project, f.project.assets[0]!).find((def) => def.key === BUS_INTO_KEY); - expect(into).toMatchObject({ kind: 'asset', assetTypes: ['bus'], none: 'Master' }); for (const type of ['sound', 'instrument', 'pattern', 'song'] as const) { const command = new CreateAssetCommand(type, type); f.stack.execute(command); - expect(schemaForAsset(f.project, command.asset).find((def) => def.key === BUS_KEY)).toMatchObject({ - none: 'Master', - assetTypes: ['bus'], - }); + expect(schemaForAsset(f.project, command.asset).map((def) => def.key)).not.toContain('bus'); } - }); - - it('offers as Into neither itself nor a bus that already goes into it', () => { - const f = withBuses('Master music', 'Music', 'Drums', 'SFX'); - const [top, music, drums, sfx] = f.ids as [string, string, string, string]; - f.into(music, top); - f.into(drums, music); - const rows = schemaForAsset( - f.project, - f.project.assets.find((a) => a.id === top)!, - ); - expect(rows.find((d) => d.key === BUS_INTO_KEY)).toMatchObject({ intoFor: top }); - expect(busesInto(f.project, top)).toEqual([top, music, drums]); - expect(busesInto(f.project, drums)).toEqual([drums]); - expect(busesInto(f.project, sfx)).toEqual([sfx]); - }); - - it('sends a bus that would come round to itself into the master, whatever the file says', () => { - const f = withBuses('A', 'B', 'C'); - const [a, b, c] = f.ids as [string, string, string]; - f.into(a, b); - f.into(b, c); - f.into(c, b); - // B and C go round each other: both go into the master. A goes into B, which is no loop. - expect([busInto(f.project, a), busInto(f.project, b), busInto(f.project, c)]).toEqual([b, '', '']); - f.into(a, a); - expect(busInto(f.project, a)).toBe(''); - f.into(a, 'gone'); - expect(busInto(f.project, a)).toBe(''); + expect(schemaForAsset(f.project, f.project.assets[0]!).map((def) => def.key)).not.toContain('into'); }); }); @@ -167,15 +134,19 @@ describe('the mix (#325)', () => { expect(sameMix(before, mixOf(f.project))).toBe(false); }); - it('plays on the bus a node picks, else the one its asset names, else the master', () => { + it('plays on the bus a node picks, else the one the routing sends its asset to, else the master', () => { const f = withBuses('Music', 'SFX'); const [music, sfx] = f.ids as [string, string]; - expect(playedBus(f.project, '', { bus: music })).toBe(`mix:${music}`); - expect(playedBus(f.project, sfx, { bus: music })).toBe(`mix:${sfx}`); - expect(playedBus(f.project, MASTER_BUS, { bus: music })).toBe(MASTER_MIX); - expect(playedBus(f.project, '', {})).toBe(MASTER_MIX); - // A bus that was deleted leaves it on the master. - expect(playedBus(f.project, '', { bus: 'gone' })).toBe(MASTER_MIX); + const sound = new CreateSoundCommand('Jump', FILE); + f.stack.execute(sound); + const id = sound.asset.id; + expect(playedBus(f.project, '', id)).toBe(MASTER_MIX); + f.into(id, music); + expect(playedBus(f.project, '', id)).toBe(`mix:${music}`); + expect(playedBus(f.project, sfx, id)).toBe(`mix:${sfx}`); + expect(playedBus(f.project, MASTER_BUS, id)).toBe(MASTER_MIX); + // A pick of a bus that was deleted leaves it on the master. + expect(playedBus(f.project, 'gone', id)).toBe(MASTER_MIX); }); it('reaches the master for an empty pick, and no bus for one that is not there', () => { @@ -219,13 +190,13 @@ function runOnStart(type: string, params: Record { - it('plays a sound on the bus its asset names, or on the one Play sound picks', () => { + it('plays a sound on the bus the routing sends it to, or on the one Play sound picks', () => { const f = makeSpriteScene(); const bus = new CreateAssetCommand('bus', 'SFX'); const sound = new CreateSoundCommand('Jump', FILE); f.stack.execute(bus); f.stack.execute(sound); - f.stack.execute(new SetAssetPropertyCommand(sound.asset.id, BUS_KEY, bus.asset.id)); + routingOf(f).only(sound.asset.id, bus.asset.id); runOnStart('PlaySound', { sound: sound.asset.id, volume: 1 }, f); const asset = busSink(); startWorld(f, { behaviors: true, audio: asset }).frame(); diff --git a/tests/core/embedded.test.ts b/tests/core/embedded.test.ts index 67f578f5..7d220c3e 100644 --- a/tests/core/embedded.test.ts +++ b/tests/core/embedded.test.ts @@ -71,8 +71,7 @@ describe('an embedded asset (#355)', () => { it('hides the rows that do nothing in a song, only while embedded', () => { const { f, takeId, blipId } = setup(); const take = findAsset(f.project, takeId)!; - expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([ - true, + expect(['loop', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([ true, false, false, @@ -308,18 +307,11 @@ describe('a pattern made in a song (#355)', () => { ]); }); - it('hides only its bus, not its clock, length, instrument or sound FX (#363)', () => { + it('hides none of its rows: not its clock, length, instrument or sound FX (#363)', () => { const { make } = withPatterns(); const own = make(); - const keys = ['bus', 'tempo', 'sign', 'bars', 'instrument', 'fx']; - expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([ - true, - false, - false, - false, - false, - false, - ]); + const keys = ['tempo', 'sign', 'bars', 'instrument', 'fx']; + expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([false, false, false, false, false]); }); it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => { diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 86e5ab3f..b831c88d 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isSongRoutingDef } from '../../src/core/music/routingNodes'; +import { isMasterRoutingDef, isSongRoutingDef } from '../../src/core/music/routingNodes'; import { requireNodeDef } from '../../src/core/commands/require'; import { CommandError } from '../../src/core/commands/Command'; import { @@ -82,9 +82,11 @@ describe('node registry', () => { else if (def.category === 'state') expect(def.run).toBeUndefined(); // A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a // stage of a voice, an instrument's own node to part of a note and a node of a song's routing - // to its buses (#350): none is ever run. + // (#350) or of the master routing (#351) to buses: none is ever run. else if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound') - expect('emit' in def || 'instrument' in def || isSongRoutingDef(def)).toBe(true); + expect('emit' in def || 'instrument' in def || isSongRoutingDef(def) || isMasterRoutingDef(def)).toBe( + true, + ); // The end of a block runs as part of the node that opens it (`Find` … `Match`). else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined(); // Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`). diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 22f78139..9f44fcb2 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -155,6 +155,7 @@ describe('CommandStack', () => { renderLayers: ['Default'], masterVolume: 1, masterFx: '', + routing: { nodes: [], edges: [] }, symbolScale: 1, playFrom: 'start' as const, whilePlaying: 'live' as const, diff --git a/tests/core/master-routing.test.ts b/tests/core/master-routing.test.ts new file mode 100644 index 00000000..482a8dae --- /dev/null +++ b/tests/core/master-routing.test.ts @@ -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([]); + }); +}); diff --git a/tests/core/pattern.test.ts b/tests/core/pattern.test.ts index 5739f58c..c2540942 100644 --- a/tests/core/pattern.test.ts +++ b/tests/core/pattern.test.ts @@ -56,7 +56,6 @@ describe('pattern asset (#333)', () => { 'sign', 'tempo', 'fx', - 'bus', ]); }); diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts index ad88bfa9..bb6302b9 100644 --- a/tests/core/song.test.ts +++ b/tests/core/song.test.ts @@ -71,13 +71,7 @@ describe('the song asset (#334)', () => { const f = makeSong(1); expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']); expect(f.song().items).toEqual([]); - expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual([ - 'tempo', - 'sign', - 'grid', - 'fx', - 'bus', - ]); + expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']); expect(songGrid(f.song().props)).toBe(BAR); f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T')); expect(songGrid(f.song().props)).toBe(160); diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index f9e8a5cf..9aad5136 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -40,7 +40,6 @@ import { VOLUME_KEY, volumeOf, } from '../../src/core/properties/sounds'; -import { BUS_KEY } from '../../src/core/properties/buses'; import { deserializeProject } from '../../src/core/store/serialize'; import type { AudioSink } from '../../src/runtime/systems/Audio'; import { @@ -131,7 +130,7 @@ describe('the sound asset', () => { expect(volumeOf(asset.props)).toBe(1); expect(loopsOf(asset.props)).toBe(false); // The cut belongs to a clip of the sound (#357), the sound is the whole file. - expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx', BUS_KEY]); + expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx']); expect(CLIP_ROWS.map((def) => def.key)).toEqual([ START_KEY, END_KEY, diff --git a/tests/editor/edge-selection.test.ts b/tests/editor/edge-selection.test.ts index 1385e8cf..7602c440 100644 --- a/tests/editor/edge-selection.test.ts +++ b/tests/editor/edge-selection.test.ts @@ -189,6 +189,7 @@ describe('a selection of links', () => { type: 'number', name: '', send: null, + tapped: false, }); expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull(); expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 6b91c3bf..9bc3cf6c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ import { CreateAssetCommand } from '../src/core/commands/assetCommands'; import { CommandStack } from '../src/core/commands/CommandStack'; -import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands'; +import { AddNodeCommand, ConnectCommand, DisconnectCommand } from '../src/core/commands/graphCommands'; import { AddPortsCommand } from '../src/core/commands/portCommands'; import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; import { requireNodeDef } from '../src/core/commands/require'; @@ -29,7 +29,9 @@ import { findObject } from '../src/core/model/hierarchy'; import { worldPosition } from '../src/core/properties/transform'; import { resolvedHolder } from '../src/core/model/instance'; import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties'; -import { createEmptyProject, findScene, type Project } from '../src/core/model/Project'; +import { createEmptyProject, findScene, type Project, ROUTING_ID } from '../src/core/model/Project'; +import { settleRouting } from '../src/core/music/projectRouting'; +import { MASTER_BUS } from '../src/core/properties/buses'; import type { SceneObject } from '../src/core/model/SceneObject'; import { deserializeProject, serializeProject } from '../src/core/store/serialize'; import type { AudioSink } from '../src/runtime/systems/Audio'; @@ -65,6 +67,28 @@ export function makeProject(): ProjectFixture { return { project, stack: new CommandStack(project) }; } +/** + * The master routing of a fixture (#351), held to its assets before each change: `link` adds a link + * from an asset to a bus or the master, `only` makes that its one link. + */ +export function routingOf({ project, stack }: ProjectFixture) { + const link = (from: string, to = MASTER_BUS) => { + settleRouting(project); + const command = new ConnectCommand(ROUTING_ID, { node: from, port: 'out' }, { node: to, port: 'in' }); + stack.execute(command); + return command.edge; + }; + return { + link, + only(from: string, to = MASTER_BUS) { + settleRouting(project); + for (const edge of project.routing.edges.filter((e) => e.from.node === from)) + stack.execute(new DisconnectCommand(ROUTING_ID, edge.id)); + return link(from, to); + }, + }; +} + /** A project with a scene "Level" and a 100×100 sprite (named "Box" unless told otherwise), plus lookups into the scene. */ export function makeSpriteScene(spriteName = 'Box'): SceneFixture { const fixture = makeProject(); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index c4934c97..475fb5ca 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -2295,6 +2295,50 @@ describe('the master and the buses of the project (#325)', () => { expect(levels(library).map((level) => level.name)).toEqual(['mix', 'mix:a']); }); + it('sends a bus of the mix along the links of the master routing, and back into one bus (#351)', async () => { + const library = new SoundLibrary(false); + await play(library); + const sends = [ + { to: 'mix', gain: 1, tap: 'post' as const }, + { to: 'mix:a', gain: 0.5, tap: 'preFx' as const }, + ]; + await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, sends }]); + const b = library.busLevels().find((level) => level.name === 'mix:b'); + expect(b?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([ + ['mix', 1, 'post'], + ['mix:a', 0.5, 'preFx'], + ]); + expect(b?.into).toBeNull(); + await library.setMix(mix); + expect(levels(library).at(-1)).toEqual({ name: 'mix:b', gain: 0.25, into: 'mix:a' }); + }); + + it('sends the master of a playing along the links of its pattern or song (#351)', async () => { + const library = new SoundLibrary(false); + await library.setMix(mix); + const id = library.playSequence( + 'song:s', + { + sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] }, + tempo: 120, + length: 1920, + loop: null, + needs: { layers: [], files: [] }, + sends: [ + { to: 'mix', gain: 1, tap: 'post' }, + { to: 'mix:b', gain: 0.25, tap: 'preFx' }, + ], + strike: () => 0, + }, + '', + ); + const master = library.busLevels().find((level) => level.name === `play:${id}`); + expect(master?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([ + ['mix', 1, 'post'], + ['mix:b', 0.25, 'preFx'], + ]); + }); + it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => { const library = new SoundLibrary(false); await library.setMix(mix); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 4f35e48c..33bf7944 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -20,7 +20,7 @@ const DRONE_BYTES = 12044; export async function sound(t) { const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t; - const { select } = t; + const { select, drag } = t; const assetsOfType = (type) => json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) .map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`); @@ -2262,55 +2262,123 @@ export async function sound(t) { JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }), ); - // 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers - // itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the - // master's volume is a project setting, one undo step. + // 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes + // one; the tab has a node for it and for the sound, each linked to the master. Links drawn + // there and one deleted send the sound into a bus and that bus into the other, as the Sound + // tab then plays it. A click on a node shows its asset, on the master the master's volume. for (let i = 0; i < 2; i++) { await clickText('+'); await pickAssetType(t, 'Bus'); await sleep(150); } const [busA, busB] = await assetsOfType('bus'); - const intoMenu = async () => { - await select({ kind: 'asset', assetId: busA.id }); - const at = await json("__gm.inspectorField('Into', true)"); - await click(at.x, at.y); - const offered = await json('__gm.overlayTexts()'); - await press('Escape'); - return offered; - }; - const offeredBefore = await intoMenu(); - await mutate(` - const p = __gm.state.project; - p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`); - const offeredAfter = await intoMenu(); + await t.clickToolbar('menu'); + await clickText('Master routing'); + await sleep(300); + const routing = () => json('__gm.routing.graph()'); + const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`); + const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() }; + const labels = await json( + `[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`, + ); + await select({ kind: 'asset', assetId: droneId }); + const busRow = await json("__gm.inspectorField('Bus', true)"); + await select({ kind: 'asset', assetId: busA.id }); + const intoRow = await json("__gm.inspectorField('Into', true)"); check( - '"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it', - [busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') && - JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) && - JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']), - JSON.stringify({ busA, busB, offeredBefore, offeredAfter }), + 'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows', + opened.shown && + [droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) && + [droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) && + JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) && + [busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) && + busRow === null && + intoRow === null, + JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }), ); + const portOf = (nodeId, key, isOutput) => + json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); + const link = async (from, to) => { + const a = await portOf(from, 'out', true); + const b = await portOf(to, 'in', false); + await drag(a.x, a.y, b.x, b.y); + await sleep(200); + }; + // The link is picked by the model: its line may run behind a node, as lines do. + const unlink = async (from, to) => { + const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to); + await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] }); + await press('Delete'); + await sleep(150); + }; + await link(droneId, busB.id); + await link(busB.id, busA.id); + await unlink(droneId, '#master'); + await unlink(busB.id, '#master'); + await shot('h3-routing'); await mutate(` - const p = __gm.state.project; - p.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5; - p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ${JSON.stringify(busB.id)};`); + __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`); await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); const startedBefore = (await stats()).started; await click(tabPlay.x, tabPlay.y); await waitFor(stats, (s) => s.started > startedBefore, 10); + await sleep(300); const buses = await json('__gm.sound.buses()'); const heardOn = (await stats()).last?.bus; + const peak = await waitFor( + () => json(`__gm.routing.peaks([${JSON.stringify(`mix:${busB.id}`)}])`), + (peaks) => Math.max(...(peaks[`mix:${busB.id}`] ?? [0])) > 0.01, + ); + await click(tabPlay.x, tabPlay.y); const levelOf = (name) => buses.find((bus) => bus.name === name); check( - 'the Sound tab plays the sound on its bus, into the bus that one names, on into the master', - heardOn === `mix:${busB.id}` && + 'linked in the routing, the Sound tab plays the sound on its bus, into the bus that one goes to, on into the master', + links(await routing()) + .filter((l) => l.startsWith(droneId) || l.startsWith(busB.id)) + .join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` && + heardOn === `mix:${busB.id}` && levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` && levelOf(`mix:${busA.id}`)?.into === 'mix' && levelOf(`mix:${busA.id}`)?.gain === 0.5 && - levelOf('mix')?.into === null, - JSON.stringify({ heardOn, buses }), + levelOf('mix')?.into === null && + Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) > 0.01, + JSON.stringify({ links: links(await routing()), heardOn, buses, peak }), + ); + + await t.clickToolbar('menu'); + await clickText('Master routing'); + await sleep(200); + let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`); + await click(at.x, at.y); + await sleep(200); + const busShown = await json("__gm.inspectorField('Volume', true)"); + at = await json("__gm.routing.nodeAt('#master')"); + await click(at.x, at.y); + await sleep(200); + await shot('h3-master'); + const masterField = await json("__gm.inspectorField('Volume', true)"); + await click(masterField.x, masterField.y); + await press('a', { ctrl: true }); + await t.typeText('0.6'); + await press('Enter'); + await sleep(150); + const masterSet = await json('__gm.state.project.masterVolume'); + await t.clickToolbar('undo'); + await sleep(150); + const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') }; + at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`); + await click(at.x, at.y); + await click(at.x, at.y); + await sleep(300); + check( + 'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound', + busShown !== null && + masterShown.set === 0.6 && + masterShown.undone === 1 && + (await json('__gm.soundTab.current()')) === droneId && + (await json('__gm.routing.shown()')) === false, + JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }), ); await t.clickToolbar('menu'); @@ -2337,10 +2405,11 @@ export async function sound(t) { mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1, JSON.stringify({ masterAt, mastered, undone }), ); + // The routing starts again from the assets with the next edit: everything on the master. await mutate(` const p = __gm.state.project; - p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ''; - p.assets = p.assets.filter((a) => a.type !== 'bus');`); + p.assets = p.assets.filter((a) => a.type !== 'bus'); + p.routing = { nodes: [], edges: [] };`); // 34i. The step takes back everything it made, so the steps after it see the fixture again. await mutate(`