diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 6efa2fd7..50d4e233 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -12,6 +12,7 @@ import type { } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import { defaultTracks } from '../music/song'; +import { heldSongGraph } from '../music/routing'; import { linearCurve } from '../model/curve'; import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; @@ -109,8 +110,10 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'instrument', graph: starterInstrumentGraph() }; case 'pattern': return { ...base, type: 'pattern', notes: [] }; - case 'song': - return { ...base, type: 'song', tracks: defaultTracks(), items: [] }; + case 'song': { + const tracks = defaultTracks(); + return { ...base, type: 'song', tracks, items: [], graph: heldSongGraph(undefined, tracks) }; + } case 'bus': return { ...base, type: 'bus' }; case 'particles': diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index c0ba7c92..5d8240e5 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -1,4 +1,5 @@ import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; +import { closesLoop } from '../graph/dropTarget'; import type { GraphLookup } from '../graph/lookup'; import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { nextTransitionName, parallelTransitions } from '../graph/stateMachine'; @@ -50,7 +51,8 @@ export class AddNodeCommand implements Command { } execute(project: Project): void { - requireNodeDef(this.node.type); + const def = requireNodeDef(this.node.type); + if (def.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`); const graph = requireGraph(project, this.assetId); // The two terminals give the behavior its signature; a second one of either would make // "which ports does this behavior have" ambiguous. @@ -79,6 +81,8 @@ export class RemoveNodeCommand implements Command { execute(project: Project): void { const graph = requireGraph(project, this.assetId); + const def = nodeDef(findNode(graph, this.nodeId)?.type ?? ''); + if (def?.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`); const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId); if (!node) throw new CommandError(`Node ${this.nodeId} not found`); // `edgesOfNode` reads the edge list only, so it still finds them after the node is gone. @@ -214,6 +218,8 @@ export class ConnectCommand implements Command { const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port); if (!out || !inp) throw new CommandError('Port not found'); if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); + if (closesLoop(graph, this.edge.from.node, this.edge.to.node, lookup)) + throw new CommandError('That link would lead round in a loop'); if (graph.edges.some((e) => sameEnds(e, this.edge))) { // Transitions are told apart by name, so a further one between the same states gets its own. if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true) diff --git a/src/core/commands/songCommands.ts b/src/core/commands/songCommands.ts index afdc6691..8a2bc32c 100644 --- a/src/core/commands/songCommands.ts +++ b/src/core/commands/songCommands.ts @@ -1,14 +1,17 @@ /** * Editing a song (#334). As with the notes of a pattern, one gesture is one command: the tracks or * items are written whole once the pointer lets go, so an undo takes the drag back. What is written - * is held to the rules of a track and an item first; an item whose track goes goes with it. + * is held to the rules of a track and an item first; an item whose track goes goes with it, and the + * routing follows the tracks (#350). */ +import type { EdgeSend, Graph } from '../graph/Graph'; import type { SongAsset } from '../model/Asset'; import type { Project } from '../model/Project'; +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 { Command } from './Command'; -import { requireSong } from './require'; +import { requireEdge, requireSong } from './require'; export interface SongParts { tracks?: readonly SongTrack[]; @@ -16,7 +19,7 @@ export interface SongParts { } export class SetSongCommand implements Command { - private previous: { tracks: SongTrack[]; items: SongItem[] } | null = null; + private previous: { tracks: SongTrack[]; items: SongItem[]; graph: Graph } | null = null; constructor( private readonly assetId: string, @@ -33,9 +36,10 @@ export class SetSongCommand implements Command { execute(project: Project): void { const asset = requireSong(project, this.assetId); - this.previous = { tracks: asset.tracks, items: asset.items }; + this.previous = { tracks: asset.tracks, items: asset.items, graph: asset.graph }; asset.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks); asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks); + asset.graph = heldSongGraph(asset.graph, asset.tracks); } undo(project: Project): void { @@ -43,6 +47,34 @@ export class SetSongCommand implements Command { const asset = requireSong(project, this.assetId); asset.tracks = this.previous.tracks; asset.items = this.previous.items; + asset.graph = this.previous.graph; + } +} + +/** How loud a link of a song's routing sends, and where it taps its track (#350). */ +export class SetSendCommand implements Command { + readonly label = 'Set send'; + private previous: EdgeSend | undefined; + + constructor( + private readonly assetId: string, + private readonly edgeId: string, + private readonly send: EdgeSend, + ) {} + + execute(project: Project): void { + requireSong(project, this.assetId); + const edge = requireEdge(project, this.assetId, this.edgeId); + this.previous = edge.send; + const held = heldSend(this.send); + if (held) edge.send = held; + else delete edge.send; + } + + undo(project: Project): void { + const edge = requireEdge(project, this.assetId, this.edgeId); + if (this.previous) edge.send = this.previous; + else delete edge.send; } } diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index fb312579..025e2412 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -92,6 +92,20 @@ export interface GraphEdge { folded?: boolean; /** What a transition between two states is called – the name `State transition` asks for. */ label?: string; + /** On a link of a song's routing (#350): how loud it sends and where it taps. 0 dB after the pan where left out. */ + send?: EdgeSend; +} + +/** + * Where a send of a song's track takes its sound (#350): after volume and pan, after the sound FX + * but before volume and pan, or right where it comes in, before the sound FX. + */ +export type SendTap = 'post' | 'postFx' | 'preFx'; + +export interface EdgeSend { + /** In decibels, as a track's volume. */ + volume: number; + tap: SendTap; } /** @@ -176,6 +190,22 @@ export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] { return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId); } +/** Whether a way along the edges leads from node `from` to node `to`; a node reaches itself. */ +export function reaches(graph: Pick, from: string, to: string): boolean { + const seen = new Set([from]); + const open = [from]; + while (open.length > 0) { + const at = open.pop()!; + if (at === to) return true; + for (const edge of graph.edges) + if (edge.from.node === at && !seen.has(edge.to.node)) { + seen.add(edge.to.node); + open.push(edge.to.node); + } + } + return false; +} + /** * What a compiled graph (shader, particles) depends on and nothing else: node ids, types, params * and declared ports, and which port feeds which. Positions, collapsing, folding and the boxes are diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 45aed267..d89c4c2f 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -149,6 +149,10 @@ export interface NodeDef { * between whole states, not as signal flowing left to right into a particular socket. */ transitionEdges?: true; + /** Comes and goes with something outside the graph, never added or deleted by hand: a song's tracks (#350). */ + fixed?: true; + /** Links between such nodes may never lead round to where they started: a song's routing (#350). */ + acyclic?: true; /** The node that comes along when this one is added, `dx` to the right: the end of its block. */ opens?: { type: string; dx: number }; /** diff --git a/src/core/graph/dropTarget.ts b/src/core/graph/dropTarget.ts index e985d6d6..672d054f 100644 --- a/src/core/graph/dropTarget.ts +++ b/src/core/graph/dropTarget.ts @@ -1,4 +1,4 @@ -import { edgesInto, findNode, type Graph, type PortType } from './Graph'; +import { edgesInto, findNode, type Graph, type PortType, reaches } from './Graph'; import type { GraphLookup } from './lookup'; import { resolveDef } from './nested'; import { type NodeDef, type PortDef, portsMeet, takesManyEdges } from './NodeRegistry'; @@ -33,7 +33,17 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: G const toPort = portOf(graph, to, lookup); if (fromPort === undefined || toPort === undefined) return false; const [out, inp] = from.isOutput ? [fromPort, toPort] : [toPort, fromPort]; - return portsMeet(out, inp) && !linked(graph, from, to); + const [outNode, inNode] = from.isOutput ? [from.nodeId, to.nodeId] : [to.nodeId, from.nodeId]; + return portsMeet(out, inp) && !linked(graph, from, to) && !closesLoop(graph, outNode, inNode, lookup); +} + +/** Whether a link from `outNode` into `inNode` would lead round to where it started, between nodes that forbid that (#350). */ +export function closesLoop(graph: Graph, outNode: string, inNode: string, lookup: GraphLookup): boolean { + const acyclic = (id: string) => { + const node = findNode(graph, id); + return node !== undefined && resolveDef(node, graph, lookup)?.acyclic === true; + }; + return acyclic(outNode) && acyclic(inNode) && reaches(graph, inNode, outNode); } /** @@ -52,6 +62,8 @@ export function dropTargetPort( const def = node && resolveDef(node, graph, lookup); const fromPort = portOf(graph, from, lookup); if (!def || fromPort === undefined) return null; + const [outNode, inNode] = from.isOutput ? [from.nodeId, nodeId] : [nodeId, from.nodeId]; + if (closesLoop(graph, outNode, inNode, lookup)) return null; const candidates = compatiblePorts(def, fromPort, from.isOutput) .map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, many: takesManyEdges(p) })) .filter((c) => !linked(graph, from, c.side)); diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 14034093..bd6b21f6 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -345,13 +345,14 @@ export interface PatternAsset extends AssetBase { /** * Sounds and patterns on tracks along one time axis (#334). The tempo, the meter and the grid are * properties; the tracks, their items and the loop are the asset's own data, the whole song while - * no loop is set. + * no loop is set. Where each track goes is its graph (#350): a node per track and one output. */ export interface SongAsset extends AssetBase { type: 'song'; tracks: SongTrack[]; items: SongItem[]; loop?: LoopRange; + graph: Graph; } /** @@ -636,7 +637,8 @@ export type GraphAsset = | ShaderFunctionAsset | ParticlesAsset | SoundFxAsset - | InstrumentAsset; + | InstrumentAsset + | SongAsset; const GRAPH_TYPES: ReadonlySet = new Set([ 'behavior', @@ -646,6 +648,7 @@ const GRAPH_TYPES: ReadonlySet = new Set([ 'particles', 'soundfx', 'instrument', + 'song', ]); export function isGraphAsset(asset: Asset): asset is GraphAsset { diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index b771b63f..2685c1af 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -1,3 +1,4 @@ +import type { SendTap } from '../graph/Graph'; import type { FxPlan, FxVoice } from '../soundfx/nodes'; import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; import type { LoopRange, Sequence } from './sequence'; @@ -15,10 +16,19 @@ export interface BusLevel { pan: number; /** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */ into?: string; + /** Where a track of a song goes instead (#350): each send on its own, `into` then left out. */ + sends?: readonly BusSend[]; /** The sound FX all of it goes through as one chain (#346), before its level and pan. */ fx?: FxVoice; } +/** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */ +export interface BusSend { + to?: string; + gain: number; + tap: SendTap; +} + /** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */ export interface SequencePlay { sequence: Sequence; diff --git a/src/core/music/routing.ts b/src/core/music/routing.ts new file mode 100644 index 00000000..0ddc5bd9 --- /dev/null +++ b/src/core/music/routing.ts @@ -0,0 +1,218 @@ +/** + * The routing of a song (#350): a graph with a node per track and one output, its links the sends + * from track to track. Everything here is plain arithmetic on that graph – what the sound library + * builds of it lives in `viewport/SoundLibrary.ts`. + */ +import { + createEmptyGraph, + type EdgeSend, + type Graph, + type GraphEdge, + type GraphNode, + reaches, + type SendTap, +} from '../graph/Graph'; +import { newId } from '../ids'; +import { SONG_OUTPUT, SONG_TRACK, SONG_TRACK_NAME } from './routingNodes'; +import { type SongTrack, TRACK_MAX_DB, TRACK_MIN_DB } from './song'; + +/** The id of the output of every song: no id `newId` makes, so it never meets a track's. */ +export const SONG_OUTPUT_ID = '#output'; +const TRACK_STEP = 96; +const OUTPUT_DX = 320; + +export const SEND_TAPS: readonly { value: SendTap; label: string }[] = [ + { value: 'post', label: 'Post volume (Post pan)' }, + { value: 'postFx', label: 'Pre volume (Post FX)' }, + { value: 'preFx', label: 'Pre volume (Pre FX)' }, +]; + +/** What a link of the routing sends: as its `send` says, else at 0 dB after volume and pan. */ +export function sendOf(edge: Pick): EdgeSend { + return edge.send ?? { volume: 0, tap: 'post' }; +} + +/** Held to what a send may be; none where it is what a link sends anyway. */ +export function heldSend(raw: unknown): EdgeSend | undefined { + if (typeof raw !== 'object' || raw === null) return undefined; + const r = raw as Record; + const volume = + typeof r.volume === 'number' && Number.isFinite(r.volume) + ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, r.volume)) + : 0; + const tap = SEND_TAPS.find((t) => t.value === r.tap)?.value ?? 'post'; + return volume === 0 && tap === 'post' ? undefined : { volume, tap }; +} + +/** + * The routing as it has to be for these tracks: a node for each, named after it, and the one + * output. A track without a node gets one below the others and a link to the output – every track + * of an old song, which had no routing yet. A node or link of nothing that is there goes, and so + * does a link that would close a loop or say again what another one says. + */ +export function heldSongGraph(graph: Graph | undefined, tracks: readonly SongTrack[]): Graph { + const given = graph ?? createEmptyGraph(); + const byId = new Map(given.nodes.map((node) => [node.id, node])); + const placed = tracks.flatMap((t) => (byId.get(t.id)?.type === SONG_TRACK ? [byId.get(t.id)!] : [])); + const left = placed.length > 0 ? Math.min(...placed.map((n) => n.x)) : 0; + let lowest = placed.length > 0 ? Math.max(...placed.map((n) => n.y)) : -TRACK_STEP; + const fresh: string[] = []; + const trackNodes: GraphNode[] = tracks.map((track) => { + const old = byId.get(track.id); + if (old?.type === SONG_TRACK) return { ...old, params: { ...old.params, [SONG_TRACK_NAME]: track.name } }; + fresh.push(track.id); + lowest += TRACK_STEP; + return { id: track.id, type: SONG_TRACK, x: left, y: lowest, params: { [SONG_TRACK_NAME]: track.name } }; + }); + const oldOutput = byId.get(SONG_OUTPUT_ID); + const middle = trackNodes.reduce((sum, n) => sum + n.y, 0) / Math.max(1, trackNodes.length); + const output: GraphNode = + oldOutput?.type === SONG_OUTPUT + ? oldOutput + : { id: SONG_OUTPUT_ID, type: SONG_OUTPUT, x: left + OUTPUT_DX, y: Math.round(middle), params: {} }; + + const ids = new Set(trackNodes.map((n) => n.id)); + const edges: GraphEdge[] = []; + const edgeIds = new Set(); + for (const edge of given.edges) { + const from = edge.from.node; + const to = edge.to.node; + const toOutput = to === SONG_OUTPUT_ID; + if (!ids.has(from) || edge.from.port !== 'out' || edge.to.port !== 'in') continue; + if ((!toOutput && !ids.has(to)) || from === to || edgeIds.has(edge.id)) continue; + if (edges.some((e) => e.from.node === from && e.to.node === to)) continue; + if (!toOutput && reaches({ edges }, to, from)) continue; + edgeIds.add(edge.id); + const send = heldSend(edge.send); + edges.push({ + id: edge.id, + from: { node: from, port: 'out' }, + to: { node: to, port: 'in' }, + ...(edge.folded && { folded: true }), + ...(send && { send }), + }); + } + for (const id of fresh) + edges.push({ id: newId(), from: { node: id, port: 'out' }, to: { node: SONG_OUTPUT_ID, port: 'in' } }); + return { + nodes: [...trackNodes, output], + edges, + ...(given.groups && { groups: given.groups }), + ...(given.notes && { notes: given.notes }), + }; +} + +/** One send of a track: into another track, or `''` into the output. */ +export interface SongRoute { + from: string; + to: string; + volume: number; + tap: SendTap; +} + +/** The sends of the tracks, as the routing of the song has them. */ +export function songRoutes(song: { tracks: readonly SongTrack[]; graph?: Graph }): SongRoute[] { + const graph = heldSongGraph(song.graph, song.tracks); + return graph.edges.map((edge) => ({ + from: edge.from.node, + to: edge.to.node === SONG_OUTPUT_ID ? '' : edge.to.node, + ...sendOf(edge), + })); +} + +/** Whether a track's send into `to` (`''` the output) is open, and whether its own items are heard. */ +export interface TrackGate { + passes: (to: string) => boolean; + own: boolean; +} + +/** + * Mute and solo along the routing. A muted track passes nothing on, whatever comes in and on every + * send. While some are soloed, a soloed track is heard with all that goes into it; the tracks it + * goes through on its way out pass it on, their own items silent; every other track is silent. + * Only the ways through a soloed track are open: a track that goes into one sends nothing past it. + */ +export function trackGates( + tracks: readonly SongTrack[], + routes: readonly SongRoute[], +): Map { + const soloed = tracks.filter((t) => t.solo).map((t) => t.id); + const gates = new Map(); + if (soloed.length === 0) { + for (const track of tracks) gates.set(track.id, { passes: () => !track.mute, own: true }); + return gates; + } + const along = (start: readonly string[], next: (id: string) => string[]) => { + const seen = new Set(start); + const open = [...start]; + while (open.length > 0) + for (const id of next(open.pop()!)) + if (id !== '' && !seen.has(id)) { + seen.add(id); + open.push(id); + } + return seen; + }; + const feeding = along(soloed, (id) => routes.filter((r) => r.to === id).map((r) => r.from)); + const fed = along(soloed, (id) => routes.filter((r) => r.from === id).map((r) => r.to)); + for (const track of tracks) + gates.set(track.id, { + // What a soloed track went through goes on everywhere; the rest only on towards a soloed one. + passes: (to) => !track.mute && (fed.has(track.id) || feeding.has(to)), + own: feeding.has(track.id), + }); + return gates; +} + +/** What is wrong with a node of the routing, by node id: a track that goes nowhere is never heard. */ +export function routingProblems(graph: Graph): Record { + const problems: Record = {}; + for (const node of graph.nodes) + if (node.type === SONG_TRACK && !graph.edges.some((e) => e.from.node === node.id)) + problems[node.id] = 'Goes nowhere: link it to the output or to another track to hear it'; + return problems; +} + +/** A bus as far as its timing goes: how late its sound FX is, and where it goes on to. */ +export interface TimedBus { + latency: number; + /** Where it goes as a whole, null straight out; left out where it has sends instead. */ + into?: string | null; + sends?: readonly { to: string; tap: SendTap }[]; +} + +/** How late a send is by its tap: one before the sound FX is not held back by it. */ +function tapLatency(bus: TimedBus, tap: SendTap): number { + return tap === 'preFx' ? 0 : bus.latency; +} + +/** + * Seconds from where sound goes in on `name` to where it is heard, along the latest of its ways – + * the others are held back to match (`sendDelays`). A bus that goes nowhere is 0; a loop counts once. + */ +export function arrivalOf(name: string, busOf: (name: string) => TimedBus | undefined): number { + const memo = new Map(); + const walking = new Set(); + const arrive = (at: string): number => { + const known = memo.get(at); + if (known !== undefined) return known; + const bus = busOf(at); + if (!bus || walking.has(at)) return 0; + walking.add(at); + const late = bus.sends + ? Math.max(0, ...bus.sends.map((s) => tapLatency(bus, s.tap) + arrive(s.to))) + : bus.latency + (bus.into ? arrive(bus.into) : 0); + walking.delete(at); + memo.set(at, late); + return late; + }; + return arrive(name); +} + +/** How long each send of `bus` is held back, so every way of it is heard as late as the latest one. */ +export function sendDelays(name: string, busOf: (name: string) => TimedBus | undefined): number[] { + const bus = busOf(name); + if (!bus?.sends) return []; + const total = arrivalOf(name, busOf); + return bus.sends.map((s) => Math.max(0, total - tapLatency(bus, s.tap) - arrivalOf(s.to, busOf))); +} diff --git a/src/core/music/routingNodes.ts b/src/core/music/routingNodes.ts new file mode 100644 index 00000000..f0057988 --- /dev/null +++ b/src/core/music/routingNodes.ts @@ -0,0 +1,41 @@ +import { registerNode, type NodeDef, type PortDef } from '../graph/NodeRegistry'; +import { textOf } from '../model/Properties'; + +/** The two nodes of a song's routing (#350): one per track, and the one output of the song. */ +export const SONG_TRACK = 'SongTrack'; +export const SONG_OUTPUT = 'SongOutput'; +/** The node param a track's node shows its track's name by, kept in step with the track. */ +export const SONG_TRACK_NAME = 'name'; + +const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio', many: true }; +const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' }; + +export const SongTrackNode = registerNode({ + type: SONG_TRACK, + label: 'Track', + category: 'sound', + description: + 'A track of the song: its items and whatever comes in, through its sound FX, volume and pan, sent on to every link.', + inputs: [audioIn], + outputs: [audioOut], + params: [], + fixed: true, + acyclic: true, + derivePorts: ({ node }) => ({ label: textOf(node.params[SONG_TRACK_NAME] ?? '') || 'Track' }), +}); + +export const SongOutputNode = registerNode({ + type: SONG_OUTPUT, + label: 'Output', + category: 'sound', + description: 'What the song sounds like: on to the bus of the song.', + inputs: [audioIn], + outputs: [], + params: [], + fixed: true, + acyclic: true, +}); + +export function isSongRoutingDef(def: NodeDef): boolean { + return def.type === SONG_TRACK || def.type === SONG_OUTPUT; +} diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 9356f88f..37174a80 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -2,7 +2,8 @@ * A song (#334): sounds and patterns laid out on tracks along one time axis, at one tempo and meter. * An item is counted in ticks like a note; a pattern item repeats its pattern for as long as it is, * a sound item its sound, which keeps its own speed whatever the tempo. A track's volume, pan, mute - * and solo are heard on a bus of its own, so a change reaches what is sounding already. + * and solo are heard on a bus of its own, so a change reaches what is sounding already; where it + * goes from there is the song's routing (#350). */ import { newId } from '../ids'; import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; @@ -127,7 +128,15 @@ export function trackBus(songId: string, trackId: string): string { return `song:${songId}:${trackId}`; } -/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track. */ +/** + * The bus a track's own items go in by, into the bus of the track (#350): what solo silences on a + * track that only passes a soloed one on. `#` is in no id, so no item's bus is called that. + */ +export function trackItemsBus(songId: string, trackId: string): string { + return `${trackBus(songId, trackId)}:#items`; +} + +/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track's items. */ export function itemBus(songId: string, item: Pick): string { return `${trackBus(songId, item.track)}:${item.id}`; } @@ -136,13 +145,6 @@ export function dbToGain(db: number): number { return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20); } -/** What a track's bus is set to: silent while muted, or while others are soloed and it is not. */ -export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { gain: number; pan: number } { - const soloed = tracks.some((t) => t.solo); - const heard = !track.mute && (!soloed || track.solo); - return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan }; -} - /** Whether a pattern item is heard on a bus of its own (#346): it names a sound FX that is there. */ export function hasItemBus(project: Project, item: SongItem): boolean { return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined; @@ -186,7 +188,7 @@ export function itemNotes( return out; } -/** What the scheduler plays of a song: every item that has its asset, on the bus of its track. */ +/** What the scheduler plays of a song: every item that has its asset, on the bus of its track's items. */ export function songSequence(project: Project, song: SongAsset): Sequence { const notes: SequenceNote[] = []; const clips: SequenceClip[] = []; @@ -194,7 +196,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence { for (const item of song.items) { if (!tracks.has(item.track)) continue; const volume = dbToGain(item.volume); - const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackBus(song.id, item.track); + const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track); if (item.kind === 'sound') { if (!findAssetOf(project, item.asset, 'sound')?.file) continue; clips.push({ diff --git a/src/core/music/songBuses.ts b/src/core/music/songBuses.ts index f681eef4..efc25afc 100644 --- a/src/core/music/songBuses.ts +++ b/src/core/music/songBuses.ts @@ -1,27 +1,64 @@ import type { Project } from '../model/Project'; import type { PropertyBag } from '../model/Properties'; +import type { Graph } from '../graph/Graph'; import { fxVoiceOf } from './needs'; import type { BusLevel } from './playbacks'; -import { hasItemBus, itemBus, type SongItem, songTempo, type SongTrack, trackBus, trackLevel } from './song'; +import { songRoutes, trackGates } from './routing'; +import { + dbToGain, + hasItemBus, + itemBus, + type SongItem, + songTempo, + type SongTrack, + trackBus, + trackItemsBus, +} from './song'; /** - * The level and pan each track's bus is set to, and the sound FX it has (#346). A pattern item with a - * sound FX has a bus of its own into its track's, at unity: its volume is its notes'. A sound FX that - * does not compile is none, and the item's bus then only passes it on. + * The buses of a song: each track's at its volume and pan, through its sound FX (#346), on to where + * its routing sends it (#350); a bus for its own items into it; and one for each pattern item with a + * sound FX into that, at unity – its volume is its notes'. Mute and solo close sends and items + * (`trackGates`). A sound FX that does not compile is none, and the bus then only passes it on. */ export function songBuses( project: Project, - song: { id: string; tracks: readonly SongTrack[]; items: readonly SongItem[]; props: PropertyBag }, + song: { + id: string; + tracks: readonly SongTrack[]; + items: readonly SongItem[]; + props: PropertyBag; + graph?: Graph; + }, ): BusLevel[] { const tempo = songTempo(song.props); const fxOf = (id: string) => { const fx = fxVoiceOf(project, id, tempo); return fx ? { fx } : {}; }; - const tracks = song.tracks.map((track) => ({ - name: trackBus(song.id, track.id), - ...trackLevel(track, song.tracks), - ...fxOf(track.fx), + const routes = songRoutes(song); + const gates = trackGates(song.tracks, routes); + const tracks = song.tracks.map((track) => { + const gate = gates.get(track.id); + return { + name: trackBus(song.id, track.id), + gain: dbToGain(track.volume), + pan: track.pan, + sends: routes + .filter((route) => route.from === track.id) + .map((route) => ({ + ...(route.to !== '' && { to: trackBus(song.id, route.to) }), + gain: gate?.passes(route.to) ? dbToGain(route.volume) : 0, + tap: route.tap, + })), + ...fxOf(track.fx), + }; + }); + const own = song.tracks.map((track) => ({ + name: trackItemsBus(song.id, track.id), + gain: gates.get(track.id)?.own ? 1 : 0, + pan: 0, + into: trackBus(song.id, track.id), })); const known = new Set(song.tracks.map((t) => t.id)); const items = song.items @@ -30,8 +67,8 @@ export function songBuses( name: itemBus(song.id, item), gain: 1, pan: 0, - into: trackBus(song.id, item.track), + into: trackItemsBus(song.id, item.track), ...fxOf(item.fx), })); - return [...tracks, ...items]; + return [...tracks, ...own, ...items]; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 214019da..6de6c3b1 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -23,6 +23,8 @@ import { isSoundFxNodeDef } from '../soundfx/nodes'; import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes'; import { heldPatternNotes } from '../music/pattern'; import { heldSongItems, heldSongTracks } from '../music/song'; +import { heldSend, heldSongGraph } from '../music/routing'; +import { isSongRoutingDef } from '../music/routingNodes'; import { heldLoop, type LoopRange } from '../music/sequence'; import { isKeyInterpolation, normalizeKeys } from '../model/animation'; import type { @@ -289,6 +291,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { tracks, items: heldSongItems(a.items, tracks), ...(loop && { loop }), + graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks), }); } else if (type === 'curve') { assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) }); @@ -585,6 +588,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { if (e.folded === true) edge.folded = true; const label = typeof e.label === 'string' ? e.label.trim() : ''; if (label) edge.label = label; + const send = heldSend(e.send); + if (send) edge.send = send; graph.edges.push(edge); } for (const kind of ANNOTATION_KINDS) { diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 0a8d45aa..8eaa0ede 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -25,7 +25,7 @@ import { structTypeUsages } from '../core/model/structTypes'; import { generatesPictures } from '../core/model/generated'; import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; -import { findAnnotation } from '../core/graph'; +import { findAnnotation, findNode, nodeDef } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; import type { SceneObject } from '../core/model/SceneObject'; import { pixelArtRefIds } from '../core/model/pixelart'; @@ -159,9 +159,11 @@ export function removeGraphSelection( ): boolean { const graph = findGraphAsset(state.project, assetId)?.graph; const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id)); + // A node that comes and goes on its own – a track of a song (#350) – stays, the rest goes. + const removable = nodeIds.filter((id) => !nodeDef((graph && findNode(graph, id))?.type ?? '')?.fixed); const commands: Command[] = [ ...links.map((e) => new DisconnectCommand(assetId, e.id)), - ...nodeIds.map((id) => new RemoveNodeCommand(assetId, id)), + ...removable.map((id) => new RemoveNodeCommand(assetId, id)), ]; let noun = links.length === 1 ? linkNoun(state, assetId) : ''; for (const id of annotationIds) { @@ -170,11 +172,14 @@ export function removeGraphSelection( noun = annotationNoun(found.kind); commands.push(new RemoveAnnotationCommand(assetId, found.kind, id)); } - if (commands.length === 0) return false; + if (commands.length === 0) { + if (nodeIds.length > 0) state.setNotice('These nodes come and go on their own and cannot be deleted'); + return false; + } const label = commands.length === 1 && noun !== '' ? `Delete ${noun}` - : nodeIds.length === commands.length + : removable.length === commands.length ? `Delete ${commands.length} nodes` : `Delete ${commands.length} items`; return state.execute(oneStep(label, commands)); diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index 3b579e46..4635e6cd 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -44,6 +44,22 @@ export function songHooks({ state, editor }: HookContext) { clock: () => soundLibrary.clock(), stats: () => soundLibrary.stats(), buses: () => soundLibrary.busLevels(), + /** Timeline or Routing (#350), and where their switches are. */ + shown: () => songEditor.view, + viewAt: (view: 'timeline' | 'routing') => + centreOf(view === 'routing' ? songEditor.routingButton : songEditor.timelineButton), + routing: { + graph: (assetId: string) => { + const asset = findAssetOf(state.project, assetId, 'song'); + return asset ? (JSON.parse(JSON.stringify(asset.graph)) as unknown) : null; + }, + portAt: (nodeId: string, port: string, isOutput: boolean) => + songEditor.routing.portGlobal(nodeId, port, isOutput), + nodeAt: (nodeId: string) => songEditor.routing.nodeGlobal(nodeId), + edgeAt: (edgeId: string) => songEditor.routing.edgeGlobal(edgeId), + label: (nodeId: string) => songEditor.routing.nodeLabel(nodeId), + problem: (nodeId: string) => songEditor.routing.nodeProblem(nodeId), + }, }, }; } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 4fb29e38..b7af6bf3 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -32,6 +32,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { soundFxBuildFor } from '../../core/soundfx/compile'; import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile'; +import { routingProblems } from '../../core/music/routing'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { ViewTween } from '../viewTween'; @@ -313,7 +314,9 @@ export class GraphCanvas extends Widget { ? soundFxBuildFor(asset).problems : asset.type === 'instrument' ? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems - : null; + : asset.type === 'song' + ? routingProblems(asset.graph) + : null; for (const node of asset.graph.nodes) { const resolved = resolveDef(node, asset.graph, lookup); if (!resolved) continue; diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 065131fb..2b065156 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -138,6 +138,8 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr 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 []; 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/graph/wiring.ts b/src/editor/graph/wiring.ts index 53efb3a0..35a2a9f4 100644 --- a/src/editor/graph/wiring.ts +++ b/src/editor/graph/wiring.ts @@ -103,7 +103,7 @@ export class GraphWiring { const graph = this.host.asset()?.graph; const from = drag.from; const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project)); - if (!graph || fromPort === undefined) return; + if (!graph || fromPort === undefined || this.host.entries().length === 0) return; const at = this.host.toGraph(global); this.pendingDrop = drag; const palette = new NodePalette( diff --git a/src/editor/inspector/edgeSection.ts b/src/editor/inspector/edgeSection.ts index ad6b979a..d5676939 100644 --- a/src/editor/inspector/edgeSection.ts +++ b/src/editor/inspector/edgeSection.ts @@ -1,5 +1,16 @@ import { NameTransitionCommand } from '../../core/commands/graphCommands'; -import { findNode, type GraphEdge, isStateNode, resolveDef, stateName } from '../../core/graph'; +import { SetSendCommand } from '../../core/commands/songCommands'; +import { + type EdgeSend, + findNode, + type GraphEdge, + isStateNode, + resolveDef, + stateName, +} from '../../core/graph'; +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'; import { graphLookup } from '../../core/model/graphLookup'; import { findGraphAsset, type Project } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; @@ -9,6 +20,22 @@ import { createFieldRow, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; const NAME_KEY = 'transition-name'; +const SEND_VOLUME: PropertyDef = { + key: 'send-volume', + label: 'Volume', + kind: 'number', + default: 0, + min: TRACK_MIN_DB, + max: TRACK_MAX_DB, + step: 0.5, +}; +const SEND_TAP: PropertyDef = { + key: 'send-tap', + label: 'Mode', + kind: 'enum', + default: 'post', + options: SEND_TAPS.map((tap) => ({ value: tap.value, label: tap.label })), +}; /** What the panel says about one link: the two ends by the names drawn on them. */ export interface EdgeDescription { @@ -20,6 +47,8 @@ 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. */ + send: EdgeSend | null; } /** Null when the link is gone. An end whose node or port is gone reads `?`. */ @@ -49,6 +78,7 @@ 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, }; } @@ -99,6 +129,26 @@ export function buildEdgeSection( host.fields.set(NAME_KEY, name); host.body.add(name.row); addNote(host, 'A State transition node takes it by this name.', 'small'); + } else if (d.send) { + const send = d.send; + // The rows outlive an edit of the other one, so what stands is read when one of them changes. + const set = (next: Partial) => { + const now = describeEdge(host.state.project, assetId, edgeId)?.send ?? send; + void host.state.execute(new SetSendCommand(assetId, edgeId, { ...now, ...next })); + }; + const volume = createFieldRow(host, SEND_VOLUME, send.volume, { + onInput: () => {}, + onChange: (next) => set({ volume: Number(next) }), + }); + 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'); } else { fact('Input', d.input); fact('Type', d.type); @@ -110,5 +160,9 @@ export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId: const d = describeEdge(host.state.project, assetId, edgeId); if (!d) return false; host.fields.get(NAME_KEY)?.setValue(d.name); + if (d.send) { + host.fields.get(SEND_VOLUME.key)?.setValue(d.send.volume); + host.fields.get(SEND_TAP.key)?.setValue(d.send.tap); + } return true; } diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 8f217dd6..a67d40b2 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -19,7 +19,7 @@ import type { Asset, SongAsset } from '../../core/model/Asset'; import { findAssetOf } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; import type { SoundStore } from '../../core/store/SoundStore'; -import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui'; +import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui'; import { Music } from '../../viewport/music'; import { playedBus } from '../../core/music/mixer'; import { soundLibrary } from '../../viewport/SoundLibrary'; @@ -32,6 +32,7 @@ import { } from '../../viewport/takeRecorder'; import { activateAsset } from '../actions'; import { AssetTabEditor } from '../AssetTabEditor'; +import { GraphCanvas } from '../graph/GraphCanvas'; import { claimTransport, releaseTransport } from '../music/transport'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; @@ -44,6 +45,9 @@ const INPUT_WIDTH = 104; /** Where the browser keeps the input picked last. */ const INPUT_KEY = 'game-maker.record-input'; +/** What the tab shows below its header: the tracks along time, or where they go (#350). */ +export type SongView = 'timeline' | 'routing'; + /** A take being recorded: the song it goes into, where, and the input it comes from. */ interface Take { recorder: TakeRecorder; @@ -61,6 +65,9 @@ interface Take { * Record (#342) plays the song from where the ruler says, once through and on past its end, and * takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own * under the one clicked last, sound and item one undo step. Esc throws it away. + * + * Routing (#350) shows the same song as a graph instead: a node per track and the output, the links + * between them the sends, edited as any graph is. */ export class SongEditor extends AssetTabEditor { /** Public for the check scripts. */ @@ -69,6 +76,12 @@ export class SongEditor extends AssetTabEditor { readonly recordButton: Button; readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH }); readonly meter = new TakeMeter(); + /** Public for the check scripts. */ + readonly timelineButton: Button; + readonly routingButton: Button; + readonly routing: GraphCanvas; + private shown: SongView = 'timeline'; + private readonly pane = new VStack(); /** Heard on the bus of the asset and the master, as in the game (#325). */ readonly music = new Music( () => this.state.project, @@ -102,6 +115,17 @@ export class SongEditor extends AssetTabEditor { tooltip: 'Record from the input while the song plays – headphones keep the song out of the take', onClick: () => this.toggleRecord(), }); + this.timelineButton = new Button('Timeline', { + toggle: true, + tooltip: 'The tracks and their items along time', + onClick: () => this.showView('timeline'), + }); + this.routingButton = new Button('Routing', { + toggle: true, + tooltip: 'Where each track goes: into the output or through other tracks', + onClick: () => this.showView('routing'), + }); + this.routing = new GraphCanvas(state, 'song'); this.inputButton.onClick = () => void this.openInputMenu(); this.gridButton.onClick = () => this.openGridMenu(); const timeline = new SongTimeline(state); @@ -126,6 +150,8 @@ export class SongEditor extends AssetTabEditor { }; timeline.onDiscardTake = () => this.stop({ discard: true }); this.mount(timeline, [ + this.timelineButton, + this.routingButton, this.playButton, this.recordButton, this.inputButton, @@ -145,6 +171,35 @@ export class SongEditor extends AssetTabEditor { return asset.type === 'song'; } + get view(): SongView { + return this.shown; + } + + /** Puts the timeline or the routing below the header; the routing is framed the first time it shows. */ + showView(view: SongView): void { + this.shown = view; + this.canvas.visible = view === 'timeline'; + this.routing.visible = view === 'routing'; + this.timelineButton.setToggled(view === 'timeline'); + this.routingButton.setToggled(view === 'routing'); + this.pane.relayout(); + if (view === 'routing') this.routing.show(this.canvas.currentAssetId); + this.syncPlayButton(); + } + + protected override body(canvas: SongTimeline): Widget { + this.pane.add(canvas, 1); + this.pane.add(this.routing, 1); + this.routing.visible = false; + this.timelineButton.setToggled(true); + return this.pane; + } + + protected override fit(): void { + if (this.shown === 'routing') this.routing.fit(); + else super.fit(); + } + get isPlaying(): boolean { return this.playingId !== ''; } @@ -459,8 +514,9 @@ export class SongEditor extends AssetTabEditor { } protected override syncExtras(asset: SongAsset | null): void { + if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? ''); this.fitButton.setEnabled(asset !== null); - this.gridButton.setEnabled(asset !== null); + this.gridButton.setEnabled(asset !== null && this.shown === 'timeline'); this.syncPlayButton(); const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar'; this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar'); diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index d43d2413..0c0ff8fb 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -23,6 +23,8 @@ import { import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; 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 type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler'; import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song'; @@ -43,6 +45,9 @@ const GLIDE = 0.03; /** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */ const DECODE_RATE = 48000; +/** The longest a send of a track is held back to meet the latest way of it (#350). */ +const MAX_SEND_DELAY = 1; + /** Seconds a stopped voice takes to fall silent, so it does not click (#324). */ const FADE = 0.01; /** How finely `peaks` cuts a sound up. */ @@ -402,11 +407,17 @@ export class SoundLibrary implements AudioSink { */ async setBuses(levels: readonly BusLevel[]): Promise { const waits: Promise[] = []; - for (const { name, gain, pan, into, fx } of levels) { + for (const { name, gain, pan, into, sends, fx } of levels) { if (into !== undefined) this.bus(into); - this.route(name, this.bus(name, into ?? null), into ?? null); + const bus = this.bus(name, into ?? null); + if (sends) + this.setSends( + bus, + sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })), + ); + else this.route(name, bus, into ?? null); this.setBus(name, gain, pan); - waits.push(this.setFx(this.bus(name), fx ?? null)); + waits.push(this.setFx(bus, fx ?? null)); } await Promise.all(waits); } @@ -431,22 +442,18 @@ export class SoundLibrary implements AudioSink { /** * Seconds what goes in on that bus is late by the time it is heard (#346): its chain and those of - * the buses it goes into. 0 straight out. + * the buses it goes into, along the latest of its sends (#350). 0 straight out. */ busLatency(name: string): number { - let late = 0; - const seen = new Set(); - for ( - let bus = this.buses.get(name || MASTER_MIX); - bus && !seen.has(bus); - bus = this.buses.get(bus.into ?? '') - ) { - seen.add(bus); - late += bus.chain?.latency ?? 0; - } - return late; + return arrivalOf(name || MASTER_MIX, this.timed); } + private readonly timed = (name: string): TimedBus | undefined => { + const bus = this.buses.get(name); + if (!bus) return undefined; + return { latency: bus.chain?.latency ?? 0, into: bus.into, ...(bus.sends && { sends: bus.sends }) }; + }; + /** What each track's bus was last set to, with whether its sound FX is there and how late (test hook). */ busLevels(): { name: string; @@ -455,13 +462,17 @@ export class SoundLibrary implements AudioSink { into: string | null; fx: boolean; latency: number; + sends?: { to: string; tap: SendTap; gain: number; delay: number }[]; }[] { return [...this.buses].map(([name, bus]) => ({ name, ...bus.set, - into: bus.into, + into: bus.sends ? null : bus.into, fx: bus.chain !== null, latency: bus.chain?.latency ?? 0, + ...(bus.sends && { + sends: bus.sends.map(({ to, tap, gain, delay }) => ({ to, tap, gain, delay: delay.delayTime.value })), + }), })); } @@ -488,6 +499,7 @@ export class SoundLibrary implements AudioSink { pan: context.createStereoPanner(), set: { gain: 1, pan: 0 }, into: target, + sends: null, fx: null, chain: null, leaving: 0, @@ -504,12 +516,78 @@ export class SoundLibrary implements AudioSink { /** Sends a bus on into another one where it went somewhere else; the master stays heard. */ private route(name: string, bus: Bus, into: string | null): void { const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX); - if (bus.into === target) return; + if (bus.into === target && !bus.sends) return; + this.dropSends(bus); bus.pan.disconnect(); bus.pan.connect(this.outOf(target)); bus.into = target; } + /** + * A track of a song on along each of its sends (#350): from its tap, through a delay that makes it + * as late as the latest way of the track, at its level, into the bus it names. Only the levels + * glide where the sends are the same ones as before. + */ + private setSends(bus: Bus, sends: readonly { to: string; gain: number; tap: SendTap }[]): void { + const now = this.audio().currentTime; + const same = + bus.sends?.length === sends.length && + bus.sends.every((old, i) => old.to === sends[i]!.to && old.tap === sends[i]!.tap); + if (same) { + bus.sends!.forEach((old, i) => { + old.gain = Math.max(0, sends[i]!.gain); + old.level.gain.setTargetAtTime(old.gain, now, GLIDE / 3); + }); + return; + } + if (bus.sends) this.dropSends(bus); + else bus.pan.disconnect(); + const context = this.audio(); + bus.sends = sends.map(({ to, gain, tap }) => { + const delay = context.createDelay(MAX_SEND_DELAY); + const level = context.createGain(); + level.gain.value = Math.max(0, gain); + delay.connect(level).connect(this.bus(to).input); + return { to, tap, gain: level.gain.value, delay, level }; + }); + this.wireTaps(bus); + this.alignSends(); + } + + /** The node a send takes its sound from: after the pan, after the sound FX, or the input. */ + private tapOf(bus: Bus, tap: SendTap): AudioNode { + return tap === 'post' ? bus.pan : tap === 'postFx' ? (bus.chain?.out ?? bus.input) : bus.input; + } + + /** Feeds every send from its tap; done again whenever a chain comes or goes, which moves a tap. */ + private wireTaps(bus: Bus): void { + for (const send of bus.sends ?? []) this.tapOf(bus, send.tap).connect(send.delay); + } + + private dropSends(bus: Bus): void { + for (const send of bus.sends ?? []) { + try { + this.tapOf(bus, send.tap).disconnect(send.delay); + } catch { + // Already cut where its chain was made again. + } + send.delay.disconnect(); + send.level.disconnect(); + } + bus.sends = null; + } + + /** Holds every send back as far as `sendDelays` says, so no way of a track is heard before another. */ + private alignSends(): void { + for (const [name, bus] of this.buses) { + if (!bus.sends) continue; + const delays = sendDelays(name, this.timed); + bus.sends.forEach((send, i) => { + send.delay.delayTime.value = Math.min(MAX_SEND_DELAY, delays[i] ?? 0); + }); + } + } + /** * The master and the buses of the project (#325): each into the bus it names, at its level unless * a running game set another, through its sound FX. A bus whose asset is gone is let go. Applied @@ -601,12 +679,15 @@ export class SoundLibrary implements AudioSink { const fx = bus.fx; if (!fx) return; const context = this.audio(); - const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [] }; + const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [], out: bus.input }; bus.input.disconnect(); const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made); out.connect(bus.gain); made.latency = latency; + made.out = out; bus.chain = made; + this.wireTaps(bus); + this.alignSends(); } private unchain(bus: Bus): void { @@ -617,6 +698,8 @@ export class SoundLibrary implements AudioSink { for (const node of chain.nodes) node.disconnect(); bus.input.disconnect(); bus.input.connect(bus.gain); + this.wireTaps(bus); + this.alignSends(); } /** @@ -725,6 +808,7 @@ export class SoundLibrary implements AudioSink { } private dropBus(name: string, bus: Bus): void { + this.dropSends(bus); this.unchain(bus); bus.input.disconnect(); bus.gain.disconnect(); @@ -799,15 +883,25 @@ export class SoundLibrary implements AudioSink { master.set = { gain: 0, pan: 0 }; master.fx = fx; const names = new Set(tracks.map((track) => track.name)); - const ordered = [...tracks].sort((a, b) => Number(a.into !== undefined) - Number(b.into !== undefined)); - for (const { name, gain, pan, into, fx: trackFx } of ordered) { - const parent = into !== undefined && names.has(into) ? groupBus(id, into) : masterName; - const track = this.bus(groupBus(id, name), parent); + const inside = (bus: string | undefined) => + bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName; + const made = tracks.map(({ name, gain, pan, fx: trackFx }) => { + const track = this.bus(groupBus(id, name), masterName); track.gain.gain.value = Math.max(0, gain); track.pan.pan.value = Math.max(-1, Math.min(1, pan)); track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value }; track.fx = trackFx ?? null; - } + return track; + }); + tracks.forEach(({ name, into, sends }, i) => { + const track = made[i]!; + if (sends) + this.setSends( + track, + sends.map((send) => ({ ...send, to: inside(send.to) })), + ); + else this.route(groupBus(id, name), track, inside(into)); + }); this.groups.add(id); } @@ -1784,14 +1878,17 @@ function groupBus(id: number, track: string): string { /** * A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan, - * then the bus it goes into or straight out; `set` is what level and pan glide to. + * then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and + * pan glide to. */ interface Bus { input: GainNode; gain: GainNode; pan: StereoPannerNode; set: { gain: number; pan: number }; + /** Where it goes as a whole while it has no sends; null straight out. */ into: string | null; + sends: BusSendNodes[] | null; /** The sound FX it is set to, and the chain made of it once its worklet is there. */ fx: FxVoice | null; chain: BusChain | null; @@ -1799,8 +1896,19 @@ interface Bus { leaving: number; } +/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */ +interface BusSendNodes { + to: string; + tap: SendTap; + gain: number; + delay: DelayNode; + level: GainNode; +} + /** The sound FX of a bus as nodes (#346): one chain everything on the bus goes through. */ interface BusChain { + /** What comes out of it, on to the level of the bus: where a send after the sound FX taps. */ + out: AudioNode; /** In the order `levelsOf` lists the numbers of its chain. */ knobs: Knob[]; /** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */ diff --git a/src/viewport/music.ts b/src/viewport/music.ts index 61b2d789..bb99ad68 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -91,11 +91,15 @@ export class Music { /** * The tracks of a song set to their level and pan, heard at once (#334), and to their sound FX - * (#346). A start waits for the chains. + * (#346). What goes to the output of the song goes to `output` (#350). A start waits for the chains. */ setBuses(levels: readonly BusLevel[]): void { const output = this.output(); - this.buses = levels.map((level) => ({ ...level, into: level.into ?? output })); + this.buses = levels.map(({ sends, ...level }) => + sends + ? { ...level, sends: sends.map((send) => ({ ...send, to: send.to ?? output })) } + : { ...level, into: level.into ?? output }, + ); this.busesReady = soundLibrary.setBuses(this.buses); } diff --git a/tests/core/bus-fx.test.ts b/tests/core/bus-fx.test.ts index c679fdd5..9dd82397 100644 --- a/tests/core/bus-fx.test.ts +++ b/tests/core/bus-fx.test.ts @@ -15,7 +15,7 @@ import { soundFxBuildFor } from '../../src/core/soundfx/compile'; import { VELOCITY_PARAM } from '../../src/core/instrument/nodes'; import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset'; import type { SequencePlay } from '../../src/core/music/playbacks'; -import { itemBus, newItem, newTrack, songSequence, trackBus } from '../../src/core/music/song'; +import { itemBus, newItem, newTrack, songSequence, trackBus, trackItemsBus } from '../../src/core/music/song'; import { songBuses } from '../../src/core/music/songBuses'; import { fxChainOf, type FxParams, type FxPlan } from '../../src/core/soundfx/nodes'; import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; @@ -104,14 +104,17 @@ describe('a sound FX on the buses of a song (#346)', () => { const f = scene(); const plan = soundFxBuildFor(f.fx).compiled!; const [one, two] = f.tracks as [string, string]; + const out = [{ gain: 1, tap: 'post' }]; expect(songBuses(f.project, f.song())).toEqual([ - { name: trackBus(f.songId, one), gain: 1, pan: 0, fx: { plan, params: {}, tempo: 120 } }, - { name: trackBus(f.songId, two), gain: 1, pan: 0 }, + { name: trackBus(f.songId, one), gain: 1, pan: 0, sends: out, fx: { plan, params: {}, tempo: 120 } }, + { name: trackBus(f.songId, two), gain: 1, pan: 0, sends: out }, + { name: trackItemsBus(f.songId, one), gain: 1, pan: 0, into: trackBus(f.songId, one) }, + { name: trackItemsBus(f.songId, two), gain: 1, pan: 0, into: trackBus(f.songId, two) }, { name: itemBus(f.songId, f.through), gain: 1, pan: 0, - into: trackBus(f.songId, two), + into: trackItemsBus(f.songId, two), fx: { plan, params: {}, tempo: 120 }, }, ]); @@ -119,9 +122,9 @@ describe('a sound FX on the buses of a song (#346)', () => { // The item through Wobble on its own bus, the one whose FX is gone on its track's, like the sound. expect(notes.map((n) => [n.tick, n.bus])).toEqual([ [0, itemBus(f.songId, f.through)], - [BAR, trackBus(f.songId, two)], + [BAR, trackItemsBus(f.songId, two)], ]); - expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackBus(f.songId, two), f.fx.id]]); + expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackItemsBus(f.songId, two), f.fx.id]]); }); it('keeps a pattern item whose sound FX does not compile on a bus of its own that passes it on', () => { @@ -138,7 +141,7 @@ describe('a sound FX on the buses of a song (#346)', () => { name: itemBus(f.songId, f.through), gain: 1, pan: 0, - into: trackBus(f.songId, f.tracks[1]!), + into: trackItemsBus(f.songId, f.tracks[1]!), }); }); @@ -158,7 +161,9 @@ describe('a sound FX on the buses of a song (#346)', () => { expect(asked?.play.buses?.map((bus) => [bus.name, bus.into ?? '', 'fx' in bus])).toEqual([ [trackBus(f.songId, f.tracks[0]!), '', true], [trackBus(f.songId, f.tracks[1]!), '', false], - [itemBus(f.songId, f.through), trackBus(f.songId, f.tracks[1]!), true], + [trackItemsBus(f.songId, f.tracks[0]!), trackBus(f.songId, f.tracks[0]!), false], + [trackItemsBus(f.songId, f.tracks[1]!), trackBus(f.songId, f.tracks[1]!), false], + [itemBus(f.songId, f.through), trackItemsBus(f.songId, f.tracks[1]!), true], ]); }); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 5f9ed09a..86e5ab3f 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { isSongRoutingDef } from '../../src/core/music/routingNodes'; import { requireNodeDef } from '../../src/core/commands/require'; import { CommandError } from '../../src/core/commands/Command'; import { @@ -80,9 +81,10 @@ describe('node registry', () => { // A state is a name in a machine; its ports only anchor transitions, so it never runs. 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 and an instrument's own node to part of a note: none is ever run. + // 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. else if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound') - expect('emit' in def || 'instrument' in def).toBe(true); + expect('emit' in def || 'instrument' in def || isSongRoutingDef(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/song-play.test.ts b/tests/core/song-play.test.ts index 101bb0ef..b5cd9e2f 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -284,7 +284,8 @@ describe('Play song', () => { [Q, 64], ]); expect(asked?.play.needs.files).toEqual([FILE]); - expect(asked?.play.buses?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([ + const tracks = asked?.play.buses?.filter((b) => b.sends); + expect(tracks?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([ [trackBus(f.songId, f.tracks[0]!), 1, 0], [trackBus(f.songId, f.tracks[1]!), 0.501, -1], [trackBus(f.songId, f.tracks[2]!), 1, 0], diff --git a/tests/core/song-routing.test.ts b/tests/core/song-routing.test.ts new file mode 100644 index 00000000..4ea132de --- /dev/null +++ b/tests/core/song-routing.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands'; +import { SetSendCommand, SetSongCommand } from '../../src/core/commands/songCommands'; +import { canConnect, type Graph } from '../../src/core/graph'; +import type { SongAsset } from '../../src/core/model/Asset'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { + arrivalOf, + heldSongGraph, + routingProblems, + SONG_OUTPUT_ID, + sendDelays, + songRoutes, + type TimedBus, +} from '../../src/core/music/routing'; +import { SONG_OUTPUT, SONG_TRACK } from '../../src/core/music/routingNodes'; +import { newTrack, type SongTrack, trackBus, trackItemsBus } from '../../src/core/music/song'; +import { songBuses } from '../../src/core/music/songBuses'; +import { makeProject, roundTrip } from '../helpers'; + +/** A song with tracks A, B, C and D, every one into the output, as a new song lays them out. */ +function makeSong() { + const f = makeProject(); + const create = new CreateAssetCommand('song', 'Tune'); + f.stack.execute(create); + const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset; + const tracks = ['A', 'B', 'C', 'D'].map((name, i) => ({ + ...newTrack(i + 1), + id: name.toLowerCase(), + name, + })); + f.stack.execute(new SetSongCommand(song().id, { tracks })); + /** Takes every link away and lays down these instead, `''` the output. */ + const route = (...links: [string, string][]) => { + song().graph.edges = []; + for (const [from, to] of links) + f.stack.execute( + new ConnectCommand( + song().id, + { node: from, port: 'out' }, + { node: to || SONG_OUTPUT_ID, port: 'in' }, + ), + ); + }; + return { ...f, song, route }; +} + +const links = (graph: Graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`); + +describe('the routing of a song (#350)', () => { + it('has a node for every track and the output, each new track linked to the output', () => { + const f = makeSong(); + const graph = f.song().graph; + expect(graph.nodes.map((n) => [n.id, n.type, n.params.name ?? ''])).toEqual([ + ['a', SONG_TRACK, 'A'], + ['b', SONG_TRACK, 'B'], + ['c', SONG_TRACK, 'C'], + ['d', SONG_TRACK, 'D'], + [SONG_OUTPUT_ID, SONG_OUTPUT, ''], + ]); + expect(links(graph)).toEqual(['a>#output', 'b>#output', 'c>#output', 'd>#output']); + // Below one another, the output to the right of them. + expect(new Set(graph.nodes.slice(0, 4).map((n) => n.x)).size).toBe(1); + expect(graph.nodes.slice(0, 4).map((n) => n.y)).toEqual( + [...graph.nodes.slice(0, 4).map((n) => n.y)].sort((a, b) => a - b), + ); + expect(graph.nodes[4]!.x).toBeGreaterThan(graph.nodes[0]!.x); + }); + + it('follows the tracks: a renamed one is renamed, a gone one takes its links along, an undo brings them back', () => { + const f = makeSong(); + f.route(['a', 'b'], ['b', ''], ['c', 'b'], ['d', '']); + const before = JSON.stringify(f.song().graph); + const [a, b, c, d] = f.song().tracks as [SongTrack, SongTrack, SongTrack, SongTrack]; + f.stack.execute(new SetSongCommand(f.song().id, { tracks: [a, { ...c, name: 'Drums' }, d] })); + expect(links(f.song().graph)).toEqual(['d>#output']); + expect(f.song().graph.nodes.find((n) => n.id === 'c')?.params.name).toBe('Drums'); + f.stack.undo(); + expect(JSON.stringify(f.song().graph)).toBe(before); + f.stack.execute(new SetSongCommand(f.song().id, { tracks: [a, b, c, d, { ...newTrack(5), id: 'e' }] })); + expect(links(f.song().graph)).toEqual(['a>b', 'b>#output', 'c>b', 'd>#output', 'e>#output']); + }); + + it('gives an old song without a routing every track into the output, and keeps what is right on load', () => { + const tracks = [ + { ...newTrack(1), id: 'a' }, + { ...newTrack(2), id: 'b' }, + ]; + expect(links(heldSongGraph(undefined, tracks))).toEqual(['a>#output', 'b>#output']); + const edge = (id: string, from: string, to: string, send?: unknown) => + ({ + id, + from: { node: from, port: 'out' }, + to: { node: to, port: 'in' }, + send, + }) as Graph['edges'][number]; + const held = heldSongGraph( + { + nodes: [ + { id: 'a', type: SONG_TRACK, x: 0, y: 0, params: {} }, + { id: 'b', type: SONG_TRACK, x: 0, y: 96, params: {} }, + { id: 'gone', type: SONG_TRACK, x: 0, y: 192, params: {} }, + ], + edges: [ + edge('1', 'a', 'b', { volume: 99, tap: 'preFx' }), + edge('2', 'b', 'a'), + edge('3', 'a', 'b'), + edge('4', 'b', 'gone'), + edge('5', 'b', SONG_OUTPUT_ID, { volume: 0, tap: 'post' }), + edge('6', 'a', 'a'), + ], + }, + tracks, + ); + // The way back from B to A would close a loop, the second A to B says what the first does. + expect(held.edges.map((e) => [e.id, e.send ?? null])).toEqual([ + ['1', { volume: 12, tap: 'preFx' }], + ['5', null], + ]); + expect(held.nodes.map((n) => n.id)).toEqual(['a', 'b', SONG_OUTPUT_ID]); + }); + + it('survives the round trip with its sends, and a send is set and undone as one step', () => { + const f = makeSong(); + const edge = f.song().graph.edges[0]!; + f.stack.execute(new SetSendCommand(f.song().id, edge.id, { volume: -6, tap: 'postFx' })); + expect(f.song().graph.edges[0]!.send).toEqual({ volume: -6, tap: 'postFx' }); + const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset; + expect(back.graph).toEqual(f.song().graph); + f.stack.undo(); + expect(f.song().graph.edges[0]!.send).toBeUndefined(); + }); + + it('never closes a loop, and neither adds nor deletes the node of a track', () => { + const f = makeSong(); + f.route(['a', 'b'], ['b', 'c'], ['c', '']); + const id = f.song().id; + const lookup = graphLookup(f.project); + const side = (nodeId: string, isOutput: boolean) => ({ nodeId, port: isOutput ? 'out' : 'in', isOutput }); + expect(canConnect(f.song().graph, side('c', true), side('a', false), lookup)).toBe(false); + expect(canConnect(f.song().graph, side('a', true), side('c', false), lookup)).toBe(true); + expect(() => + f.stack.execute(new ConnectCommand(id, { node: 'c', port: 'out' }, { node: 'a', port: 'in' })), + ).toThrow('loop'); + expect(() => f.stack.execute(new RemoveNodeCommand(id, 'a'))).toThrow('on its own'); + expect(() => f.stack.execute(new RemoveNodeCommand(id, SONG_OUTPUT_ID))).toThrow('on its own'); + expect(() => f.stack.execute(new AddNodeCommand(id, SONG_TRACK, 0, 0))).toThrow('on its own'); + expect(f.song().graph.nodes).toHaveLength(5); + }); + + it('says of a track that goes nowhere that it is not heard', () => { + const f = makeSong(); + f.route(['a', ''], ['b', 'a']); + expect(Object.keys(routingProblems(f.song().graph))).toEqual(['c', 'd']); + }); +}); + +describe('what the buses of a routed song are set to (#350)', () => { + it('sends a track into the buses its links go to, each at its level and from its tap', () => { + const f = makeSong(); + f.route(['a', 'b'], ['a', ''], ['b', '']); + const ab = f.song().graph.edges[0]!; + f.stack.execute(new SetSendCommand(f.song().id, ab.id, { volume: -6, tap: 'preFx' })); + const buses = songBuses(f.project, f.song()); + const id = f.song().id; + const sends = buses.find((bus) => bus.name === trackBus(id, 'a'))?.sends; + expect(sends?.map((send) => [send.to ?? '', +send.gain.toFixed(3), send.tap])).toEqual([ + [trackBus(id, 'b'), 0.501, 'preFx'], + ['', 1, 'post'], + ]); + expect(buses.find((bus) => bus.name === trackBus(id, 'c'))?.sends).toEqual([]); + expect(buses.find((bus) => bus.name === trackItemsBus(id, 'a'))).toEqual({ + name: trackItemsBus(id, 'a'), + gain: 1, + pan: 0, + into: trackBus(id, 'a'), + }); + }); + + /** C into A into B into the output, D into the output: how loud each track's sends and items are. */ + function heard( + change: (tracks: SongTrack[]) => SongTrack[], + routes: [string, string][] = [ + ['c', 'a'], + ['a', 'b'], + ['b', ''], + ['d', ''], + ], + ) { + const f = makeSong(); + f.route(...routes); + const song = { ...f.song(), tracks: change(f.song().tracks) }; + const buses = songBuses(f.project, song); + const of = (name: string) => buses.find((bus) => bus.name === name)!; + return Object.fromEntries( + ['a', 'b', 'c', 'd'].map((t) => [ + t, + [ + of(trackBus(song.id, t)) + .sends!.map((s) => s.gain) + .join(), + of(trackItemsBus(song.id, t)).gain, + ], + ]), + ); + } + + it('lets a soloed track be heard with what goes into it, through the tracks it goes through', () => { + expect(heard((tracks) => tracks)).toEqual({ a: ['1', 1], b: ['1', 1], c: ['1', 1], d: ['1', 1] }); + // B passes A on, its own items silent; C goes into A and is heard; D has nothing to do with A. + expect(heard(([a, ...rest]) => [{ ...a!, solo: true }, ...rest])).toEqual({ + a: ['1', 1], + b: ['1', 0], + c: ['1', 1], + d: ['0', 0], + }); + }); + + it('sends nothing past a soloed track that a track goes into besides', () => { + // A and B into C and into the output, C into the output: with C soloed, A and B only reach it. + const routes: [string, string][] = [ + ['a', 'c'], + ['a', ''], + ['b', 'c'], + ['b', ''], + ['c', ''], + ]; + expect(heard(([a, b, c, d]) => [a!, b!, { ...c!, solo: true }, d!], routes)).toEqual({ + a: ['1,0', 1], + b: ['1,0', 1], + c: ['1', 1], + d: ['', 0], + }); + }); + + it('silences everything a muted track passes on, also what comes into it', () => { + expect(heard(([a, b, ...rest]) => [a!, { ...b!, mute: true }, ...rest])).toEqual({ + a: ['1', 1], + b: ['0', 1], + c: ['1', 1], + d: ['1', 1], + }); + expect(heard(([a, b, ...rest]) => [{ ...a!, solo: true }, { ...b!, mute: true }, ...rest])).toMatchObject( + { + b: ['0', 0], + }, + ); + }); + + it('reads the sends of a song made before #350 as every track into the output', () => { + const tracks = [{ ...newTrack(1), id: 'a' }]; + expect(songRoutes({ tracks })).toEqual([{ from: 'a', to: '', volume: 0, tap: 'post' }]); + }); +}); + +describe('the timing of the ways through a song (#350)', () => { + const LATE = 2048 / 48000; + /** A with a Pitch into the output after it and before it; B into A and into the output. */ + const buses: Record = { + out: { latency: 0, into: null }, + a: { + latency: LATE, + sends: [ + { to: 'out', tap: 'post' }, + { to: 'out', tap: 'preFx' }, + ], + }, + b: { + latency: 0, + sends: [ + { to: 'a', tap: 'post' }, + { to: 'out', tap: 'postFx' }, + ], + }, + items: { latency: 0, into: 'b' }, + }; + const busOf = (name: string) => buses[name]; + + it('makes a track as late as its latest way, and holds every other way back to match', () => { + expect(arrivalOf('a', busOf)).toBeCloseTo(LATE, 12); + expect(arrivalOf('b', busOf)).toBeCloseTo(LATE, 12); + expect(arrivalOf('items', busOf)).toBeCloseTo(LATE, 12); + expect(sendDelays('a', busOf)).toEqual([0, expect.closeTo(LATE, 12)]); + expect(sendDelays('b', busOf)).toEqual([0, expect.closeTo(LATE, 12)]); + expect(sendDelays('out', busOf)).toEqual([]); + }); + + it('counts a loop once instead of running round it', () => { + const looped: Record = { + x: { latency: LATE, sends: [{ to: 'y', tap: 'post' }] }, + y: { latency: LATE, sends: [{ to: 'x', tap: 'post' }] }, + }; + expect(arrivalOf('x', (name) => looped[name])).toBeCloseTo(2 * LATE, 12); + }); +}); diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts index ff405b10..f7deae63 100644 --- a/tests/core/song.test.ts +++ b/tests/core/song.test.ts @@ -10,13 +10,14 @@ import { newItem, newTrack, type SongItem, + type SongTrack, songGrid, songLength, songLoop, songSequence, - trackBus, - trackLevel, + trackItemsBus, } from '../../src/core/music/song'; +import { songBuses } from '../../src/core/music/songBuses'; import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler'; import { clipsIn, type Sequence } from '../../src/core/music/sequence'; import { secondsOfTicks } from '../../src/core/music/time'; @@ -161,9 +162,9 @@ describe('what a song plays (#334)', () => { [2 * BAR + 960, 960, 64], [3 * BAR, 480, 60], ]); - expect(notes.every((n) => n.instrument === f.piano && n.bus === trackBus(f.song().id, f.track(0)))).toBe( - true, - ); + expect( + notes.every((n) => n.instrument === f.piano && n.bus === trackItemsBus(f.song().id, f.track(0))), + ).toBe(true); }); it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => { @@ -202,22 +203,25 @@ describe('what a song plays (#334)', () => { fx: 'fx1', offset: 0.25, volume: 1, - bus: trackBus(f.song().id, f.track(2)), + bus: trackItemsBus(f.song().id, f.track(2)), }, ], }); }); it('silences a muted track, and while any track is soloed every one that is not', () => { - const f = makeSong(); + const f = makeSong(2); const [a, b] = f.song().tracks; - const level = trackLevel({ ...a!, volume: -6, pan: 0.5 }, f.song().tracks); + const sends = (tracks: SongTrack[]) => + songBuses(f.project, { ...f.song(), tracks }).flatMap((bus) => + bus.sends ? [bus.sends.map((s) => s.gain)] : [], + ); + const level = songBuses(f.project, { ...f.song(), tracks: [{ ...a!, volume: -6, pan: 0.5 }, b!] })[0]!; expect(level.gain).toBeCloseTo(0.501, 3); expect(level.pan).toBe(0.5); - expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0); - const soloed = [{ ...a!, solo: true }, b!]; - expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]); - expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(0); + expect(sends([a!, b!])).toEqual([[1], [1]]); + expect(sends([{ ...a!, mute: true }, b!])).toEqual([[0], [1]]); + expect(sends([{ ...a!, solo: true }, b!])).toEqual([[1], [0]]); }); it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => { @@ -309,7 +313,7 @@ describe('clips on the scheduler (#334)', () => { ); scheduler.start(); expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ note: 60, bus: trackBus(f.song().id, f.track(0)) }); + expect(notes[0]).toMatchObject({ note: 60, bus: trackItemsBus(f.song().id, f.track(0)) }); expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3); }); }); diff --git a/tests/editor/edge-selection.test.ts b/tests/editor/edge-selection.test.ts index cbcc91f7..1385e8cf 100644 --- a/tests/editor/edge-selection.test.ts +++ b/tests/editor/edge-selection.test.ts @@ -188,6 +188,7 @@ describe('a selection of links', () => { input: 'b', type: 'number', name: '', + send: null, }); 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/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 345469ea..7501da73 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -1979,6 +1979,144 @@ describe('a sound FX on a bus (#346)', () => { }); }); +describe('the sends of the tracks of a song (#350)', () => { + const LATE = 2048 / 48000; + const voice = (plan: FxPlan): FxVoice => ({ plan, params: {}, tempo: 120 }); + const up: FxPlan = { pitch: [{ value: 12 }], stages: [] }; + const crush: FxPlan = planOf({ pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] }); + const out = { name: 'song:s:o', gain: 1, pan: 0 }; + const sendsOf = (library: SoundLibrary, name: string) => + library.busLevels().find((bus) => bus.name === name)?.sends; + + it('taps each send where its mode says: after the pan, after the sound FX, or where it comes in', async () => { + const library = new SoundLibrary(); + await library.setBuses([ + out, + { + name: 'song:s:t', + gain: 0.5, + pan: 0, + fx: voice(crush), + sends: [ + { to: 'song:s:o', gain: 1, tap: 'post' }, + { to: 'song:s:o', gain: 0.25, tap: 'postFx' }, + { to: 'song:s:o', gain: 0.5, tap: 'preFx' }, + ], + }, + ]); + expect(wires).toEqual( + expect.arrayContaining(['panner > delay', 'crush > delay', 'gain > delay', 'delay > gain']), + ); + expect(sendsOf(library, 'song:s:t')).toEqual([ + { to: 'song:s:o', tap: 'post', gain: 1, delay: 0 }, + { to: 'song:s:o', tap: 'postFx', gain: 0.25, delay: 0 }, + { to: 'song:s:o', tap: 'preFx', gain: 0.5, delay: 0 }, + ]); + // A track with sends is not also sent on as a whole. + expect(library.busLevels().find((bus) => bus.name === 'song:s:t')?.into).toBeNull(); + }); + + it('holds back the ways that pass a Pitch by, so all of a track is heard as late as its latest way', async () => { + const library = new SoundLibrary(); + await library.setBuses([ + out, + { + name: 'song:s:t', + gain: 1, + pan: 0, + fx: voice(up), + sends: [ + { to: 'song:s:o', gain: 1, tap: 'post' }, + { to: 'song:s:o', gain: 1, tap: 'preFx' }, + ], + }, + { + name: 'song:s:u', + gain: 1, + pan: 0, + sends: [ + { to: 'song:s:t', gain: 1, tap: 'post' }, + { to: 'song:s:o', gain: 1, tap: 'post' }, + ], + }, + ]); + expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12); + expect(library.busLatency('song:s:u')).toBeCloseTo(LATE, 12); + expect(sendsOf(library, 'song:s:t')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]); + expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]); + // Without its Pitch nothing of the track is late any more, and nothing is held back. + await library.setBuses([ + out, + { + name: 'song:s:t', + gain: 1, + pan: 0, + sends: [ + { to: 'song:s:o', gain: 1, tap: 'post' }, + { to: 'song:s:o', gain: 1, tap: 'preFx' }, + ], + }, + ]); + expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, 0]); + }); + + it('glides the level of a send that only changed its level, and makes the sends anew that go elsewhere', async () => { + const library = new SoundLibrary(); + const track = { name: 'song:s:t', gain: 1, pan: 0 }; + await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 1, tap: 'post' as const }] }]); + const made = delays.length; + await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 0, tap: 'post' as const }] }]); + expect(delays).toHaveLength(made); + expect(sendsOf(library, 'song:s:t')).toEqual([{ to: 'song:s:o', tap: 'post', gain: 0, delay: 0 }]); + await library.setBuses([out, { ...track, sends: [{ to: 'mix', gain: 1, tap: 'postFx' as const }] }]); + expect(delays).toHaveLength(made + 1); + expect(disconnects).toEqual(expect.arrayContaining(['delay'])); + // Back to one way out as a whole: no sends left. + await library.setBuses([out, track]); + expect(library.busLevels().find((bus) => bus.name === 'song:s:t')).toEqual({ + name: 'song:s:t', + gain: 1, + pan: 0, + into: 'mix', + fx: false, + latency: 0, + }); + }); + + it('sends the tracks of a game’s playing into one another and into its master', () => { + const library = new SoundLibrary(); + const id = library.playSequence( + 'song:s', + { + sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60, bus: 't' }] }, + tempo: 120, + length: 1920, + loop: null, + needs: { layers: [], files: [] }, + buses: [ + { + name: 't', + gain: 1, + pan: 0, + sends: [ + { to: 'u', gain: 0.5, tap: 'post' }, + { gain: 1, tap: 'preFx' }, + ], + }, + { name: 'u', gain: 1, pan: 0, sends: [{ gain: 1, tap: 'post' }] }, + ], + strike: () => 0, + }, + '', + ); + expect(sendsOf(library, `play:${id}:t`)?.map((send) => [send.to, send.gain, send.tap])).toEqual([ + [`play:${id}:u`, 0.5, 'post'], + [`play:${id}`, 1, 'preFx'], + ]); + expect(sendsOf(library, `play:${id}:u`)?.map((send) => send.to)).toEqual([`play:${id}`]); + }); +}); + describe('the master and the buses of the project (#325)', () => { const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({ name, diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 0682b0ab..9e9b7c40 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -443,6 +443,8 @@ export async function song(t) { 60, ); const busOf = (track) => `song:${id}:${track}`; + /** Where a track's own items go in, on to its bus (#350). */ + const itemsOf = (track) => `${busOf(track)}:#items`; const firstClip = played.clips[0]; const notes = played.planned.slice(0, 2); check( @@ -450,10 +452,10 @@ export async function song(t) { played.playing && Boolean(firstClip) && firstClip.sound === SOUND && - firstClip.bus === busOf(tracks[0]) && + firstClip.bus === itemsOf(tracks[0]) && Math.abs(firstClip.duration - 2) < 1e-9 && notes.length === 2 && - notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(tracks[2]) && n.tempo === 120) && + notes.every((n) => n.instrument === 'sg65-ins' && n.bus === itemsOf(tracks[2]) && n.tempo === 120) && Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 && Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 && notes[0].note === 60, @@ -469,13 +471,15 @@ export async function song(t) { JSON.stringify(buses), ); await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); - const muted = await waitFor( - () => json('__gm.song.buses()'), - (list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0, - ); + // Mute closes every send of the track (#350); its own level stays as it is set. + const closed = (list) => { + const sends = list.find((b) => b.name === busOf(tracks[0]))?.sends ?? []; + return sends.length > 0 && sends.every((send) => send.gain === 0); + }; + const muted = await waitFor(() => json('__gm.song.buses()'), closed); check( - 'muting a track while it plays silences its bus at once', - muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')), + 'muting a track while it plays closes its sends at once', + closed(muted) && (await json('__gm.song.playing()')), JSON.stringify(muted), ); await shot('02-playing'); @@ -603,8 +607,8 @@ export async function song(t) { run.owner !== '' && run.clips.length === 1 && gameClip?.sound === SOUND && - gameClip.bus === busOf(tracks[0]) && - gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) && + gameClip.bus === itemsOf(tracks[0]) && + gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) && Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 && Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 && Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6, @@ -837,8 +841,8 @@ export async function song(t) { 'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time', fxBus?.fx === true && Math.abs(fxBus.latency - frame) < 1e-9 && - fxNote?.bus === busOf(tracks[1]) && - plainClip?.bus === busOf(tracks[0]) && + fxNote?.bus === itemsOf(tracks[1]) && + plainClip?.bus === itemsOf(tracks[0]) && Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 && Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 && Math.abs(clipStart - plainClip.at) < 1e-6, @@ -855,6 +859,123 @@ export async function song(t) { JSON.stringify(released), ); + // 65l. Routing (#350): the switch in the header shows the song as a graph, a node per track and + // the output, every track linked to it. A drag from one track's out into another's in sends + // it there, one back would close a loop and is refused, Delete takes a link but not a track. + // A track linked nowhere says it is not heard. Volume and Mode of a link in the inspector + // are what its send is set to when the song plays. + await evaluate('__gm.sound.stopAll()'); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`); + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); + p = await json("__gm.song.viewAt('routing')"); + await click(p.x, p.y); + await sleep(300); + const routingOf = () => json(`__gm.song.routing.graph(${JSON.stringify(id)})`); + // The tracks as they are now: more than in 65a. + const ids = (await model()).tracks.map((track) => track.id); + const linksOf = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`); + const opened65 = await json(`(() => { + const graph = __gm.song.routing.graph(${JSON.stringify(id)}); + return { shown: __gm.song.shown(), graph, labels: graph.nodes.map((n) => __gm.song.routing.label(n.id)) }; + })()`); + const names = (await model()).tracks.map((track) => track.name); + await shot('05-routing'); + check( + 'Routing shows a node per track and the output, every track linked to the output', + opened65.shown === 'routing' && + JSON.stringify(opened65.graph.nodes.map((n) => n.id)) === JSON.stringify([...ids, '#output']) && + JSON.stringify(opened65.labels) === JSON.stringify([...names, 'Output']) && + JSON.stringify(linksOf(opened65.graph)) === JSON.stringify(ids.map((track) => `${track}>#output`)), + JSON.stringify(opened65), + ); + const port = (nodeId, key, isOutput) => + json(`__gm.song.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); + let fromPort = await port(ids[0], 'out', true); + let toPort = await port(ids[1], 'in', false); + await drag(fromPort.x, fromPort.y, toPort.x, toPort.y); + await sleep(200); + const linked = await routingOf(); + fromPort = await port(ids[1], 'out', true); + toPort = await port(ids[0], 'in', false); + await drag(fromPort.x, fromPort.y, toPort.x, toPort.y); + await sleep(200); + const refused = await routingOf(); + check( + 'a drag from a track into another links them, one back would close a loop and is refused', + linksOf(linked).includes(`${ids[0]}>${ids[1]}`) && + linked.edges.length === ids.length + 1 && + JSON.stringify(refused.edges) === JSON.stringify(linked.edges), + JSON.stringify({ linked: linksOf(linked), refused: linksOf(refused) }), + ); + const toOutput = (graph, track) => + graph.edges.find((e) => e.from.node === track && e.to.node === '#output'); + for (const track of [ids[0], ids[2]]) { + p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(toOutput(await routingOf(), track).id)})`); + await click(p.x, p.y); + await press('Delete'); + await sleep(150); + } + p = await json(`__gm.song.routing.nodeAt(${JSON.stringify(ids[3])})`); + await click(p.x, p.y); + await press('Delete'); + await sleep(150); + const trimmed = await routingOf(); + const nowhere = await json(`__gm.song.routing.problem(${JSON.stringify(ids[2])})`); + check( + 'Delete takes a link but never a track, and a track linked nowhere says it is not heard', + !toOutput(trimmed, ids[0]) && + !toOutput(trimmed, ids[2]) && + trimmed.nodes.length === ids.length + 1 && + String(nowhere).startsWith('Goes nowhere') && + (await json(`__gm.song.routing.problem(${JSON.stringify(ids[0])})`)) === null, + JSON.stringify({ links: linksOf(trimmed), nodes: trimmed.nodes.length, nowhere }), + ); + const send = trimmed.edges.find((e) => e.from.node === ids[0] && e.to.node === ids[1]); + p = await json(`__gm.song.routing.edgeAt(${JSON.stringify(send.id)})`); + await click(p.x, p.y); + await sleep(200); + p = await json("__gm.inspectorField('Volume')"); + await click(p.x, p.y); + await t.typeText('-6'); + await press('Enter'); + await sleep(150); + p = await json("__gm.inspectorField('Mode')"); + await click(p.x, p.y); + await sleep(150); + await clickText('Pre volume (Pre FX)'); + await sleep(150); + const set = (await routingOf()).edges.find((e) => e.id === send.id); + await shot('06-send'); + check( + 'Volume and Mode of a link in the inspector set its send', + set?.send?.volume === -6 && set.send.tap === 'preFx', + JSON.stringify(set), + ); + p = await json('__gm.song.playAt()'); + await click(p.x, p.y); + const sending = await waitFor( + () => json('__gm.song.buses()'), + (list) => list.some((b) => b.name === busOf(ids[0]) && b.sends), + ); + const sends = (track) => sending.find((b) => b.name === busOf(track))?.sends; + check( + 'playing, each track goes where its links say: through the send set, nowhere for the one without', + sends(ids[0])?.length === 1 && + sends(ids[0])[0].to === busOf(ids[1]) && + sends(ids[0])[0].tap === 'preFx' && + Math.abs(sends(ids[0])[0].gain - 10 ** (-6 / 20)) < 1e-6 && + sends(ids[1])?.length === 1 && + sends(ids[2])?.length === 0, + JSON.stringify(sending.filter((b) => b.name.startsWith(`song:${id}:`))), + ); + await click(p.x, p.y); + p = await json("__gm.song.viewAt('timeline')"); + await click(p.x, p.y); + await sleep(150); + // 65j. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(`