Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab has a second view, Routing: a graph with a node per track and one Output, the links between them sends. A track can go into other tracks, into several at once, and a link has its own volume and the point of the track it taps. - The song asset has a graph like an instrument (SongTrack, SongOutput). heldSongGraph keeps it in step with the tracks on load and in SetSongCommand: a node per track named after it, one output, a new track and every track of an old song linked to the output. Links of gone tracks, doubled links and links closing a loop are dropped. - A link carries an optional send (volume in dB, tap). The inspector shows Volume and Mode for a picked link of a song: Post volume (Post pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand. - NodeDef has two new flags. `fixed` nodes are never added or deleted by hand. Links between `acyclic` nodes may not close a loop, checked in canConnect, dropTargetPort and ConnectCommand. - songBuses gives each track its sends instead of one `into`, and a bus `song:<song>:<track>:#items` for its own items. That bus is what solo closes on a track that only passes a soloed one on. - Mute and solo now act on the sends (trackGates). A muted track passes nothing on, on any tap. A soloed track is heard with what goes into it, through the tracks it goes through, their own items silent. Only sends on a way through a soloed track stay open, so a track going into it and into the output is heard through it only. - SoundLibrary: a bus has either `into` or sends. Each send is tap, delay, level, target. busLatency takes the latest way (arrivalOf), and each send is held back so all ways of a track arrive together (sendDelays). Taps are wired again whenever a chain comes or goes. - The Song tab has Timeline and Routing in its header. The routing uses GraphCanvas with an empty palette. A track linked nowhere shows "Goes nowhere". Delete takes links, never a track node. - Game playings route the same way, the output being the master of the playing. Decided without asking: a send ranges -60 to +12 dB like a track, default 0 dB Post. Mute closes the sends, the track's own level stays. Found on the way: the inspector built the Mode row with the send as it was when the rows were made. Setting Volume and then Mode wrote the old volume back. Both rows now read the current send when they change. Tests: - Unit song-routing (new): 13 green. Sound library: 4 new, 94 green. 11 counter-probes (loop on load, canConnect, solo, solo past the soloed track, mute, preFx latency, graph sync, delete guard, delays, taps, glide), each red. - Existing tests adjusted to the new bus names and send shape: song, bus-fx, song-play, graph registry, edge description. - e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first run caught the inspector bug. Counter-probe with the old closure: 2 FAILED. - Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red (registry rule, edge shape), fixed and rerun locally: 56 green. - Not rerun after the per-send solo fix: e2e 65 and the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a52f2290f
commit
daeffe0f9d
30 files changed
+1320
-101
No files matched your search
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from '../model/Asset';
|
} from '../model/Asset';
|
||||||
import type { AssetType } from '../model/AssetTypes';
|
import type { AssetType } from '../model/AssetTypes';
|
||||||
import { defaultTracks } from '../music/song';
|
import { defaultTracks } from '../music/song';
|
||||||
|
import { heldSongGraph } from '../music/routing';
|
||||||
import { linearCurve } from '../model/curve';
|
import { linearCurve } from '../model/curve';
|
||||||
import { isFontFile } from '../model/font';
|
import { isFontFile } from '../model/font';
|
||||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
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() };
|
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
|
||||||
case 'pattern':
|
case 'pattern':
|
||||||
return { ...base, type: 'pattern', notes: [] };
|
return { ...base, type: 'pattern', notes: [] };
|
||||||
case 'song':
|
case 'song': {
|
||||||
return { ...base, type: 'song', tracks: defaultTracks(), items: [] };
|
const tracks = defaultTracks();
|
||||||
|
return { ...base, type: 'song', tracks, items: [], graph: heldSongGraph(undefined, tracks) };
|
||||||
|
}
|
||||||
case 'bus':
|
case 'bus':
|
||||||
return { ...base, type: 'bus' };
|
return { ...base, type: 'bus' };
|
||||||
case 'particles':
|
case 'particles':
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
|
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 type { GraphLookup } from '../graph/lookup';
|
||||||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||||||
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
|
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
|
||||||
@@ -50,7 +51,8 @@ export class AddNodeCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
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);
|
const graph = requireGraph(project, this.assetId);
|
||||||
// The two terminals give the behavior its signature; a second one of either would make
|
// The two terminals give the behavior its signature; a second one of either would make
|
||||||
// "which ports does this behavior have" ambiguous.
|
// "which ports does this behavior have" ambiguous.
|
||||||
@@ -79,6 +81,8 @@ export class RemoveNodeCommand implements Command {
|
|||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const graph = requireGraph(project, this.assetId);
|
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);
|
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
|
||||||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
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.
|
// `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);
|
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
|
||||||
if (!out || !inp) throw new CommandError('Port not found');
|
if (!out || !inp) throw new CommandError('Port not found');
|
||||||
if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
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))) {
|
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.
|
// Transitions are told apart by name, so a further one between the same states gets its own.
|
||||||
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
|
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Editing a song (#334). As with the notes of a pattern, one gesture is one command: the tracks or
|
* 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
|
* 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 { SongAsset } from '../model/Asset';
|
||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
|
import { heldSend, heldSongGraph } from '../music/routing';
|
||||||
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
||||||
import { heldLoop, type LoopRange } from '../music/sequence';
|
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||||
import type { Command } from './Command';
|
import type { Command } from './Command';
|
||||||
import { requireSong } from './require';
|
import { requireEdge, requireSong } from './require';
|
||||||
|
|
||||||
export interface SongParts {
|
export interface SongParts {
|
||||||
tracks?: readonly SongTrack[];
|
tracks?: readonly SongTrack[];
|
||||||
@@ -16,7 +19,7 @@ export interface SongParts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SetSongCommand implements Command {
|
export class SetSongCommand implements Command {
|
||||||
private previous: { tracks: SongTrack[]; items: SongItem[] } | null = null;
|
private previous: { tracks: SongTrack[]; items: SongItem[]; graph: Graph } | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
@@ -33,9 +36,10 @@ export class SetSongCommand implements Command {
|
|||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = requireSong(project, this.assetId);
|
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.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks);
|
||||||
asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks);
|
asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks);
|
||||||
|
asset.graph = heldSongGraph(asset.graph, asset.tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
@@ -43,6 +47,34 @@ export class SetSongCommand implements Command {
|
|||||||
const asset = requireSong(project, this.assetId);
|
const asset = requireSong(project, this.assetId);
|
||||||
asset.tracks = this.previous.tracks;
|
asset.tracks = this.previous.tracks;
|
||||||
asset.items = this.previous.items;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ export interface GraphEdge {
|
|||||||
folded?: boolean;
|
folded?: boolean;
|
||||||
/** What a transition between two states is called – the name `State transition` asks for. */
|
/** What a transition between two states is called – the name `State transition` asks for. */
|
||||||
label?: string;
|
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);
|
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<Graph, 'edges'>, 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
|
* 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
|
* and declared ports, and which port feeds which. Positions, collapsing, folding and the boxes are
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export interface NodeDef {
|
|||||||
* between whole states, not as signal flowing left to right into a particular socket.
|
* between whole states, not as signal flowing left to right into a particular socket.
|
||||||
*/
|
*/
|
||||||
transitionEdges?: true;
|
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. */
|
/** The node that comes along when this one is added, `dx` to the right: the end of its block. */
|
||||||
opens?: { type: string; dx: number };
|
opens?: { type: string; dx: number };
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 type { GraphLookup } from './lookup';
|
||||||
import { resolveDef } from './nested';
|
import { resolveDef } from './nested';
|
||||||
import { type NodeDef, type PortDef, portsMeet, takesManyEdges } from './NodeRegistry';
|
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);
|
const toPort = portOf(graph, to, lookup);
|
||||||
if (fromPort === undefined || toPort === undefined) return false;
|
if (fromPort === undefined || toPort === undefined) return false;
|
||||||
const [out, inp] = from.isOutput ? [fromPort, toPort] : [toPort, fromPort];
|
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 def = node && resolveDef(node, graph, lookup);
|
||||||
const fromPort = portOf(graph, from, lookup);
|
const fromPort = portOf(graph, from, lookup);
|
||||||
if (!def || fromPort === undefined) return null;
|
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)
|
const candidates = compatiblePorts(def, fromPort, from.isOutput)
|
||||||
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, many: takesManyEdges(p) }))
|
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, many: takesManyEdges(p) }))
|
||||||
.filter((c) => !linked(graph, from, c.side));
|
.filter((c) => !linked(graph, from, c.side));
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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 {
|
export interface SongAsset extends AssetBase {
|
||||||
type: 'song';
|
type: 'song';
|
||||||
tracks: SongTrack[];
|
tracks: SongTrack[];
|
||||||
items: SongItem[];
|
items: SongItem[];
|
||||||
loop?: LoopRange;
|
loop?: LoopRange;
|
||||||
|
graph: Graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -636,7 +637,8 @@ export type GraphAsset =
|
|||||||
| ShaderFunctionAsset
|
| ShaderFunctionAsset
|
||||||
| ParticlesAsset
|
| ParticlesAsset
|
||||||
| SoundFxAsset
|
| SoundFxAsset
|
||||||
| InstrumentAsset;
|
| InstrumentAsset
|
||||||
|
| SongAsset;
|
||||||
|
|
||||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
||||||
'behavior',
|
'behavior',
|
||||||
@@ -646,6 +648,7 @@ const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
|||||||
'particles',
|
'particles',
|
||||||
'soundfx',
|
'soundfx',
|
||||||
'instrument',
|
'instrument',
|
||||||
|
'song',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SendTap } from '../graph/Graph';
|
||||||
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||||
import type { LoopRange, Sequence } from './sequence';
|
import type { LoopRange, Sequence } from './sequence';
|
||||||
@@ -15,10 +16,19 @@ export interface BusLevel {
|
|||||||
pan: number;
|
pan: number;
|
||||||
/** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */
|
/** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */
|
||||||
into?: string;
|
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. */
|
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
|
||||||
fx?: FxVoice;
|
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. */
|
/** 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 {
|
export interface SequencePlay {
|
||||||
sequence: Sequence;
|
sequence: Sequence;
|
||||||
|
|||||||
@@ -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<GraphEdge, 'send'>): 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<string, unknown>;
|
||||||
|
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<string>();
|
||||||
|
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<string, TrackGate> {
|
||||||
|
const soloed = tracks.filter((t) => t.solo).map((t) => t.id);
|
||||||
|
const gates = new Map<string, TrackGate>();
|
||||||
|
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<string, string> {
|
||||||
|
const problems: Record<string, string> = {};
|
||||||
|
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<string, number>();
|
||||||
|
const walking = new Set<string>();
|
||||||
|
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)));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+13
-11
@@ -2,7 +2,8 @@
|
|||||||
* A song (#334): sounds and patterns laid out on tracks along one time axis, at one tempo and meter.
|
* 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,
|
* 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
|
* 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 { newId } from '../ids';
|
||||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||||
@@ -127,7 +128,15 @@ export function trackBus(songId: string, trackId: string): string {
|
|||||||
return `song:${songId}:${trackId}`;
|
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<SongItem, 'id' | 'track'>): string {
|
export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): string {
|
||||||
return `${trackBus(songId, item.track)}:${item.id}`;
|
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);
|
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. */
|
/** 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 {
|
export function hasItemBus(project: Project, item: SongItem): boolean {
|
||||||
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
|
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
|
||||||
@@ -186,7 +188,7 @@ export function itemNotes(
|
|||||||
return out;
|
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 {
|
export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||||
const notes: SequenceNote[] = [];
|
const notes: SequenceNote[] = [];
|
||||||
const clips: SequenceClip[] = [];
|
const clips: SequenceClip[] = [];
|
||||||
@@ -194,7 +196,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
|||||||
for (const item of song.items) {
|
for (const item of song.items) {
|
||||||
if (!tracks.has(item.track)) continue;
|
if (!tracks.has(item.track)) continue;
|
||||||
const volume = dbToGain(item.volume);
|
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 (item.kind === 'sound') {
|
||||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||||
clips.push({
|
clips.push({
|
||||||
|
|||||||
@@ -1,27 +1,64 @@
|
|||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import type { PropertyBag } from '../model/Properties';
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
import type { Graph } from '../graph/Graph';
|
||||||
import { fxVoiceOf } from './needs';
|
import { fxVoiceOf } from './needs';
|
||||||
import type { BusLevel } from './playbacks';
|
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
|
* The buses of a song: each track's at its volume and pan, through its sound FX (#346), on to where
|
||||||
* sound FX has a bus of its own into its track's, at unity: its volume is its notes'. A sound FX that
|
* its routing sends it (#350); a bus for its own items into it; and one for each pattern item with a
|
||||||
* does not compile is none, and the item's bus then only passes it on.
|
* 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(
|
export function songBuses(
|
||||||
project: Project,
|
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[] {
|
): BusLevel[] {
|
||||||
const tempo = songTempo(song.props);
|
const tempo = songTempo(song.props);
|
||||||
const fxOf = (id: string) => {
|
const fxOf = (id: string) => {
|
||||||
const fx = fxVoiceOf(project, id, tempo);
|
const fx = fxVoiceOf(project, id, tempo);
|
||||||
return fx ? { fx } : {};
|
return fx ? { fx } : {};
|
||||||
};
|
};
|
||||||
const tracks = song.tracks.map((track) => ({
|
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),
|
name: trackBus(song.id, track.id),
|
||||||
...trackLevel(track, song.tracks),
|
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),
|
...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 known = new Set(song.tracks.map((t) => t.id));
|
||||||
const items = song.items
|
const items = song.items
|
||||||
@@ -30,8 +67,8 @@ export function songBuses(
|
|||||||
name: itemBus(song.id, item),
|
name: itemBus(song.id, item),
|
||||||
gain: 1,
|
gain: 1,
|
||||||
pan: 0,
|
pan: 0,
|
||||||
into: trackBus(song.id, item.track),
|
into: trackItemsBus(song.id, item.track),
|
||||||
...fxOf(item.fx),
|
...fxOf(item.fx),
|
||||||
}));
|
}));
|
||||||
return [...tracks, ...items];
|
return [...tracks, ...own, ...items];
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,8 @@ import { isSoundFxNodeDef } from '../soundfx/nodes';
|
|||||||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||||
import { heldPatternNotes } from '../music/pattern';
|
import { heldPatternNotes } from '../music/pattern';
|
||||||
import { heldSongItems, heldSongTracks } from '../music/song';
|
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 { heldLoop, type LoopRange } from '../music/sequence';
|
||||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||||
import type {
|
import type {
|
||||||
@@ -289,6 +291,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
tracks,
|
tracks,
|
||||||
items: heldSongItems(a.items, tracks),
|
items: heldSongItems(a.items, tracks),
|
||||||
...(loop && { loop }),
|
...(loop && { loop }),
|
||||||
|
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
|
||||||
});
|
});
|
||||||
} else if (type === 'curve') {
|
} else if (type === 'curve') {
|
||||||
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
|
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;
|
if (e.folded === true) edge.folded = true;
|
||||||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||||||
if (label) edge.label = label;
|
if (label) edge.label = label;
|
||||||
|
const send = heldSend(e.send);
|
||||||
|
if (send) edge.send = send;
|
||||||
graph.edges.push(edge);
|
graph.edges.push(edge);
|
||||||
}
|
}
|
||||||
for (const kind of ANNOTATION_KINDS) {
|
for (const kind of ANNOTATION_KINDS) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { structTypeUsages } from '../core/model/structTypes';
|
|||||||
import { generatesPictures } from '../core/model/generated';
|
import { generatesPictures } from '../core/model/generated';
|
||||||
import { isTemplateContent } from '../core/model/instance';
|
import { isTemplateContent } from '../core/model/instance';
|
||||||
import type { Command } from '../core/commands/Command';
|
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 { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||||
import type { SceneObject } from '../core/model/SceneObject';
|
import type { SceneObject } from '../core/model/SceneObject';
|
||||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||||
@@ -159,9 +159,11 @@ export function removeGraphSelection(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const graph = findGraphAsset(state.project, assetId)?.graph;
|
const graph = findGraphAsset(state.project, assetId)?.graph;
|
||||||
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
|
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[] = [
|
const commands: Command[] = [
|
||||||
...links.map((e) => new DisconnectCommand(assetId, e.id)),
|
...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) : '';
|
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
|
||||||
for (const id of annotationIds) {
|
for (const id of annotationIds) {
|
||||||
@@ -170,11 +172,14 @@ export function removeGraphSelection(
|
|||||||
noun = annotationNoun(found.kind);
|
noun = annotationNoun(found.kind);
|
||||||
commands.push(new RemoveAnnotationCommand(assetId, found.kind, id));
|
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 =
|
const label =
|
||||||
commands.length === 1 && noun !== ''
|
commands.length === 1 && noun !== ''
|
||||||
? `Delete ${noun}`
|
? `Delete ${noun}`
|
||||||
: nodeIds.length === commands.length
|
: removable.length === commands.length
|
||||||
? `Delete ${commands.length} nodes`
|
? `Delete ${commands.length} nodes`
|
||||||
: `Delete ${commands.length} items`;
|
: `Delete ${commands.length} items`;
|
||||||
return state.execute(oneStep(label, commands));
|
return state.execute(oneStep(label, commands));
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
clock: () => soundLibrary.clock(),
|
clock: () => soundLibrary.clock(),
|
||||||
stats: () => soundLibrary.stats(),
|
stats: () => soundLibrary.stats(),
|
||||||
buses: () => soundLibrary.busLevels(),
|
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),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
|
|||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||||
|
import { routingProblems } from '../../core/music/routing';
|
||||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||||
import { ViewTween } from '../viewTween';
|
import { ViewTween } from '../viewTween';
|
||||||
@@ -313,6 +314,8 @@ export class GraphCanvas extends Widget {
|
|||||||
? soundFxBuildFor(asset).problems
|
? soundFxBuildFor(asset).problems
|
||||||
: asset.type === 'instrument'
|
: asset.type === 'instrument'
|
||||||
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
|
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
|
||||||
|
: asset.type === 'song'
|
||||||
|
? routingProblems(asset.graph)
|
||||||
: null;
|
: null;
|
||||||
for (const node of asset.graph.nodes) {
|
for (const node of asset.graph.nodes) {
|
||||||
const resolved = resolveDef(node, asset.graph, lookup);
|
const resolved = resolveDef(node, asset.graph, lookup);
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
|||||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||||
}
|
}
|
||||||
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
||||||
|
// A song's routing holds the nodes of its tracks and its output, which come and go on their own (#350).
|
||||||
|
if (asset.type === 'song') return [];
|
||||||
if (asset.type === 'soundfx') {
|
if (asset.type === 'soundfx') {
|
||||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||||
return SOUND_FX_NODES.filter(
|
return SOUND_FX_NODES.filter(
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class GraphWiring {
|
|||||||
const graph = this.host.asset()?.graph;
|
const graph = this.host.asset()?.graph;
|
||||||
const from = drag.from;
|
const from = drag.from;
|
||||||
const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project));
|
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);
|
const at = this.host.toGraph(global);
|
||||||
this.pendingDrop = drag;
|
this.pendingDrop = drag;
|
||||||
const palette = new NodePalette(
|
const palette = new NodePalette(
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
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 { graphLookup } from '../../core/model/graphLookup';
|
||||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
import { textOf } from '../../core/model/Properties';
|
import { textOf } from '../../core/model/Properties';
|
||||||
@@ -9,6 +20,22 @@ import { createFieldRow, labelledRow } from './fieldFactory';
|
|||||||
import { addNote, addTitle, type InspectorHost } from './host';
|
import { addNote, addTitle, type InspectorHost } from './host';
|
||||||
|
|
||||||
const NAME_KEY = 'transition-name';
|
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. */
|
/** What the panel says about one link: the two ends by the names drawn on them. */
|
||||||
export interface EdgeDescription {
|
export interface EdgeDescription {
|
||||||
@@ -20,6 +47,8 @@ export interface EdgeDescription {
|
|||||||
/** What flows through it, as the output names it. */
|
/** What flows through it, as the output names it. */
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** On a link of a song's routing (#350): what it sends; null on any other link. */
|
||||||
|
send: EdgeSend | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
|
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
|
||||||
@@ -49,6 +78,7 @@ export function describeEdge(project: Project, assetId: string, edgeId: string):
|
|||||||
input: to.port,
|
input: to.port,
|
||||||
type: from.type,
|
type: from.type,
|
||||||
name: edge.label ?? '',
|
name: edge.label ?? '',
|
||||||
|
send: findGraphAsset(project, assetId)?.type === 'song' ? sendOf(edge) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +129,26 @@ export function buildEdgeSection(
|
|||||||
host.fields.set(NAME_KEY, name);
|
host.fields.set(NAME_KEY, name);
|
||||||
host.body.add(name.row);
|
host.body.add(name.row);
|
||||||
addNote(host, 'A State transition node takes it by this name.', 'small');
|
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<EdgeSend>) => {
|
||||||
|
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 {
|
} else {
|
||||||
fact('Input', d.input);
|
fact('Input', d.input);
|
||||||
fact('Type', d.type);
|
fact('Type', d.type);
|
||||||
@@ -110,5 +160,9 @@ export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId:
|
|||||||
const d = describeEdge(host.state.project, assetId, edgeId);
|
const d = describeEdge(host.state.project, assetId, edgeId);
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
host.fields.get(NAME_KEY)?.setValue(d.name);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ import type { Asset, SongAsset } from '../../core/model/Asset';
|
|||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import { textOf } from '../../core/model/Properties';
|
import { textOf } from '../../core/model/Properties';
|
||||||
import type { SoundStore } from '../../core/store/SoundStore';
|
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 { Music } from '../../viewport/music';
|
||||||
import { playedBus } from '../../core/music/mixer';
|
import { playedBus } from '../../core/music/mixer';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../viewport/takeRecorder';
|
} from '../../viewport/takeRecorder';
|
||||||
import { activateAsset } from '../actions';
|
import { activateAsset } from '../actions';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
|
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||||
import { claimTransport, releaseTransport } from '../music/transport';
|
import { claimTransport, releaseTransport } from '../music/transport';
|
||||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
@@ -44,6 +45,9 @@ const INPUT_WIDTH = 104;
|
|||||||
/** Where the browser keeps the input picked last. */
|
/** Where the browser keeps the input picked last. */
|
||||||
const INPUT_KEY = 'game-maker.record-input';
|
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. */
|
/** A take being recorded: the song it goes into, where, and the input it comes from. */
|
||||||
interface Take {
|
interface Take {
|
||||||
recorder: TakeRecorder;
|
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
|
* 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
|
* 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.
|
* 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<SongAsset, SongTimeline> {
|
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
@@ -69,6 +76,12 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
readonly recordButton: Button;
|
readonly recordButton: Button;
|
||||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||||
readonly meter = new TakeMeter();
|
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). */
|
/** Heard on the bus of the asset and the master, as in the game (#325). */
|
||||||
readonly music = new Music(
|
readonly music = new Music(
|
||||||
() => this.state.project,
|
() => this.state.project,
|
||||||
@@ -102,6 +115,17 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
||||||
onClick: () => this.toggleRecord(),
|
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.inputButton.onClick = () => void this.openInputMenu();
|
||||||
this.gridButton.onClick = () => this.openGridMenu();
|
this.gridButton.onClick = () => this.openGridMenu();
|
||||||
const timeline = new SongTimeline(state);
|
const timeline = new SongTimeline(state);
|
||||||
@@ -126,6 +150,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
};
|
};
|
||||||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||||||
this.mount(timeline, [
|
this.mount(timeline, [
|
||||||
|
this.timelineButton,
|
||||||
|
this.routingButton,
|
||||||
this.playButton,
|
this.playButton,
|
||||||
this.recordButton,
|
this.recordButton,
|
||||||
this.inputButton,
|
this.inputButton,
|
||||||
@@ -145,6 +171,35 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
return asset.type === 'song';
|
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 {
|
get isPlaying(): boolean {
|
||||||
return this.playingId !== '';
|
return this.playingId !== '';
|
||||||
}
|
}
|
||||||
@@ -459,8 +514,9 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override syncExtras(asset: SongAsset | null): void {
|
protected override syncExtras(asset: SongAsset | null): void {
|
||||||
|
if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? '');
|
||||||
this.fitButton.setEnabled(asset !== null);
|
this.fitButton.setEnabled(asset !== null);
|
||||||
this.gridButton.setEnabled(asset !== null);
|
this.gridButton.setEnabled(asset !== null && this.shown === 'timeline');
|
||||||
this.syncPlayButton();
|
this.syncPlayButton();
|
||||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||||||
|
|||||||
+132
-24
@@ -23,6 +23,8 @@ import {
|
|||||||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
||||||
import type { EaseShare } from '../core/model/animation';
|
import type { EaseShare } from '../core/model/animation';
|
||||||
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
|
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
|
||||||
|
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
|
||||||
|
import type { SendTap } from '../core/graph/Graph';
|
||||||
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
|
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
|
||||||
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
||||||
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||||
@@ -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. */
|
/** 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;
|
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). */
|
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
|
||||||
const FADE = 0.01;
|
const FADE = 0.01;
|
||||||
/** How finely `peaks` cuts a sound up. */
|
/** How finely `peaks` cuts a sound up. */
|
||||||
@@ -402,11 +407,17 @@ export class SoundLibrary implements AudioSink {
|
|||||||
*/
|
*/
|
||||||
async setBuses(levels: readonly BusLevel[]): Promise<void> {
|
async setBuses(levels: readonly BusLevel[]): Promise<void> {
|
||||||
const waits: Promise<void>[] = [];
|
const waits: Promise<void>[] = [];
|
||||||
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);
|
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);
|
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);
|
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
|
* 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 {
|
busLatency(name: string): number {
|
||||||
let late = 0;
|
return arrivalOf(name || MASTER_MIX, this.timed);
|
||||||
const seen = new Set<Bus>();
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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). */
|
/** What each track's bus was last set to, with whether its sound FX is there and how late (test hook). */
|
||||||
busLevels(): {
|
busLevels(): {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -455,13 +462,17 @@ export class SoundLibrary implements AudioSink {
|
|||||||
into: string | null;
|
into: string | null;
|
||||||
fx: boolean;
|
fx: boolean;
|
||||||
latency: number;
|
latency: number;
|
||||||
|
sends?: { to: string; tap: SendTap; gain: number; delay: number }[];
|
||||||
}[] {
|
}[] {
|
||||||
return [...this.buses].map(([name, bus]) => ({
|
return [...this.buses].map(([name, bus]) => ({
|
||||||
name,
|
name,
|
||||||
...bus.set,
|
...bus.set,
|
||||||
into: bus.into,
|
into: bus.sends ? null : bus.into,
|
||||||
fx: bus.chain !== null,
|
fx: bus.chain !== null,
|
||||||
latency: bus.chain?.latency ?? 0,
|
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(),
|
pan: context.createStereoPanner(),
|
||||||
set: { gain: 1, pan: 0 },
|
set: { gain: 1, pan: 0 },
|
||||||
into: target,
|
into: target,
|
||||||
|
sends: null,
|
||||||
fx: null,
|
fx: null,
|
||||||
chain: null,
|
chain: null,
|
||||||
leaving: 0,
|
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. */
|
/** 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 {
|
private route(name: string, bus: Bus, into: string | null): void {
|
||||||
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
|
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.disconnect();
|
||||||
bus.pan.connect(this.outOf(target));
|
bus.pan.connect(this.outOf(target));
|
||||||
bus.into = 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
|
* 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
|
* 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;
|
const fx = bus.fx;
|
||||||
if (!fx) return;
|
if (!fx) return;
|
||||||
const context = this.audio();
|
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();
|
bus.input.disconnect();
|
||||||
const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made);
|
const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made);
|
||||||
out.connect(bus.gain);
|
out.connect(bus.gain);
|
||||||
made.latency = latency;
|
made.latency = latency;
|
||||||
|
made.out = out;
|
||||||
bus.chain = made;
|
bus.chain = made;
|
||||||
|
this.wireTaps(bus);
|
||||||
|
this.alignSends();
|
||||||
}
|
}
|
||||||
|
|
||||||
private unchain(bus: Bus): void {
|
private unchain(bus: Bus): void {
|
||||||
@@ -617,6 +698,8 @@ export class SoundLibrary implements AudioSink {
|
|||||||
for (const node of chain.nodes) node.disconnect();
|
for (const node of chain.nodes) node.disconnect();
|
||||||
bus.input.disconnect();
|
bus.input.disconnect();
|
||||||
bus.input.connect(bus.gain);
|
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 {
|
private dropBus(name: string, bus: Bus): void {
|
||||||
|
this.dropSends(bus);
|
||||||
this.unchain(bus);
|
this.unchain(bus);
|
||||||
bus.input.disconnect();
|
bus.input.disconnect();
|
||||||
bus.gain.disconnect();
|
bus.gain.disconnect();
|
||||||
@@ -799,15 +883,25 @@ export class SoundLibrary implements AudioSink {
|
|||||||
master.set = { gain: 0, pan: 0 };
|
master.set = { gain: 0, pan: 0 };
|
||||||
master.fx = fx;
|
master.fx = fx;
|
||||||
const names = new Set(tracks.map((track) => track.name));
|
const names = new Set(tracks.map((track) => track.name));
|
||||||
const ordered = [...tracks].sort((a, b) => Number(a.into !== undefined) - Number(b.into !== undefined));
|
const inside = (bus: string | undefined) =>
|
||||||
for (const { name, gain, pan, into, fx: trackFx } of ordered) {
|
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;
|
||||||
const parent = into !== undefined && names.has(into) ? groupBus(id, into) : masterName;
|
const made = tracks.map(({ name, gain, pan, fx: trackFx }) => {
|
||||||
const track = this.bus(groupBus(id, name), parent);
|
const track = this.bus(groupBus(id, name), masterName);
|
||||||
track.gain.gain.value = Math.max(0, gain);
|
track.gain.gain.value = Math.max(0, gain);
|
||||||
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
|
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
|
||||||
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
|
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
|
||||||
track.fx = trackFx ?? null;
|
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);
|
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,
|
* 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 {
|
interface Bus {
|
||||||
input: GainNode;
|
input: GainNode;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
pan: StereoPannerNode;
|
pan: StereoPannerNode;
|
||||||
set: { gain: number; pan: number };
|
set: { gain: number; pan: number };
|
||||||
|
/** Where it goes as a whole while it has no sends; null straight out. */
|
||||||
into: string | null;
|
into: string | null;
|
||||||
|
sends: BusSendNodes[] | null;
|
||||||
/** The sound FX it is set to, and the chain made of it once its worklet is there. */
|
/** The sound FX it is set to, and the chain made of it once its worklet is there. */
|
||||||
fx: FxVoice | null;
|
fx: FxVoice | null;
|
||||||
chain: BusChain | null;
|
chain: BusChain | null;
|
||||||
@@ -1799,8 +1896,19 @@ interface Bus {
|
|||||||
leaving: number;
|
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. */
|
/** The sound FX of a bus as nodes (#346): one chain everything on the bus goes through. */
|
||||||
interface BusChain {
|
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. */
|
/** In the order `levelsOf` lists the numbers of its chain. */
|
||||||
knobs: Knob[];
|
knobs: Knob[];
|
||||||
/** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */
|
/** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */
|
||||||
|
|||||||
@@ -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
|
* 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 {
|
setBuses(levels: readonly BusLevel[]): void {
|
||||||
const output = this.output();
|
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);
|
this.busesReady = soundLibrary.setBuses(this.buses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { soundFxBuildFor } from '../../src/core/soundfx/compile';
|
|||||||
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
||||||
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
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 { songBuses } from '../../src/core/music/songBuses';
|
||||||
import { fxChainOf, type FxParams, type FxPlan } from '../../src/core/soundfx/nodes';
|
import { fxChainOf, type FxParams, type FxPlan } from '../../src/core/soundfx/nodes';
|
||||||
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
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 f = scene();
|
||||||
const plan = soundFxBuildFor(f.fx).compiled!;
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
const [one, two] = f.tracks as [string, string];
|
const [one, two] = f.tracks as [string, string];
|
||||||
|
const out = [{ gain: 1, tap: 'post' }];
|
||||||
expect(songBuses(f.project, f.song())).toEqual([
|
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, one), gain: 1, pan: 0, sends: out, fx: { plan, params: {}, tempo: 120 } },
|
||||||
{ name: trackBus(f.songId, two), gain: 1, pan: 0 },
|
{ 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),
|
name: itemBus(f.songId, f.through),
|
||||||
gain: 1,
|
gain: 1,
|
||||||
pan: 0,
|
pan: 0,
|
||||||
into: trackBus(f.songId, two),
|
into: trackItemsBus(f.songId, two),
|
||||||
fx: { plan, params: {}, tempo: 120 },
|
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.
|
// 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([
|
expect(notes.map((n) => [n.tick, n.bus])).toEqual([
|
||||||
[0, itemBus(f.songId, f.through)],
|
[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', () => {
|
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),
|
name: itemBus(f.songId, f.through),
|
||||||
gain: 1,
|
gain: 1,
|
||||||
pan: 0,
|
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([
|
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[0]!), '', true],
|
||||||
[trackBus(f.songId, f.tracks[1]!), '', false],
|
[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],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isSongRoutingDef } from '../../src/core/music/routingNodes';
|
||||||
import { requireNodeDef } from '../../src/core/commands/require';
|
import { requireNodeDef } from '../../src/core/commands/require';
|
||||||
import { CommandError } from '../../src/core/commands/Command';
|
import { CommandError } from '../../src/core/commands/Command';
|
||||||
import {
|
import {
|
||||||
@@ -80,9 +81,10 @@ describe('node registry', () => {
|
|||||||
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
|
// 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();
|
else if (def.category === 'state') expect(def.run).toBeUndefined();
|
||||||
// A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a
|
// A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a
|
||||||
// stage of a voice 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')
|
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`).
|
// The end of a block runs as part of the node that opens it (`Find` … `Match`).
|
||||||
else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined();
|
else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined();
|
||||||
// Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`).
|
// Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`).
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ describe('Play song', () => {
|
|||||||
[Q, 64],
|
[Q, 64],
|
||||||
]);
|
]);
|
||||||
expect(asked?.play.needs.files).toEqual([FILE]);
|
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[0]!), 1, 0],
|
||||||
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
|
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
|
||||||
[trackBus(f.songId, f.tracks[2]!), 1, 0],
|
[trackBus(f.songId, f.tracks[2]!), 1, 0],
|
||||||
|
|||||||
@@ -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<string, TimedBus> = {
|
||||||
|
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<string, TimedBus> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-13
@@ -10,13 +10,14 @@ import {
|
|||||||
newItem,
|
newItem,
|
||||||
newTrack,
|
newTrack,
|
||||||
type SongItem,
|
type SongItem,
|
||||||
|
type SongTrack,
|
||||||
songGrid,
|
songGrid,
|
||||||
songLength,
|
songLength,
|
||||||
songLoop,
|
songLoop,
|
||||||
songSequence,
|
songSequence,
|
||||||
trackBus,
|
trackItemsBus,
|
||||||
trackLevel,
|
|
||||||
} from '../../src/core/music/song';
|
} from '../../src/core/music/song';
|
||||||
|
import { songBuses } from '../../src/core/music/songBuses';
|
||||||
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
|
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
|
||||||
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
|
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
|
||||||
import { secondsOfTicks } from '../../src/core/music/time';
|
import { secondsOfTicks } from '../../src/core/music/time';
|
||||||
@@ -161,9 +162,9 @@ describe('what a song plays (#334)', () => {
|
|||||||
[2 * BAR + 960, 960, 64],
|
[2 * BAR + 960, 960, 64],
|
||||||
[3 * BAR, 480, 60],
|
[3 * BAR, 480, 60],
|
||||||
]);
|
]);
|
||||||
expect(notes.every((n) => n.instrument === f.piano && n.bus === trackBus(f.song().id, f.track(0)))).toBe(
|
expect(
|
||||||
true,
|
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', () => {
|
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',
|
fx: 'fx1',
|
||||||
offset: 0.25,
|
offset: 0.25,
|
||||||
volume: 1,
|
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', () => {
|
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 [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.gain).toBeCloseTo(0.501, 3);
|
||||||
expect(level.pan).toBe(0.5);
|
expect(level.pan).toBe(0.5);
|
||||||
expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0);
|
expect(sends([a!, b!])).toEqual([[1], [1]]);
|
||||||
const soloed = [{ ...a!, solo: true }, b!];
|
expect(sends([{ ...a!, mute: true }, b!])).toEqual([[0], [1]]);
|
||||||
expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]);
|
expect(sends([{ ...a!, solo: true }, b!])).toEqual([[1], [0]]);
|
||||||
expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => {
|
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();
|
scheduler.start();
|
||||||
expect(notes).toHaveLength(1);
|
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);
|
expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ describe('a selection of links', () => {
|
|||||||
input: 'b',
|
input: 'b',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
name: '',
|
name: '',
|
||||||
|
send: null,
|
||||||
});
|
});
|
||||||
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
|
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
|
||||||
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
|
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('the master and the buses of the project (#325)', () => {
|
||||||
const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({
|
const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({
|
||||||
name,
|
name,
|
||||||
|
|||||||
+133
-12
@@ -443,6 +443,8 @@ export async function song(t) {
|
|||||||
60,
|
60,
|
||||||
);
|
);
|
||||||
const busOf = (track) => `song:${id}:${track}`;
|
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 firstClip = played.clips[0];
|
||||||
const notes = played.planned.slice(0, 2);
|
const notes = played.planned.slice(0, 2);
|
||||||
check(
|
check(
|
||||||
@@ -450,10 +452,10 @@ export async function song(t) {
|
|||||||
played.playing &&
|
played.playing &&
|
||||||
Boolean(firstClip) &&
|
Boolean(firstClip) &&
|
||||||
firstClip.sound === SOUND &&
|
firstClip.sound === SOUND &&
|
||||||
firstClip.bus === busOf(tracks[0]) &&
|
firstClip.bus === itemsOf(tracks[0]) &&
|
||||||
Math.abs(firstClip.duration - 2) < 1e-9 &&
|
Math.abs(firstClip.duration - 2) < 1e-9 &&
|
||||||
notes.length === 2 &&
|
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[0].at - firstClip.at - 4) < 1e-6 &&
|
||||||
Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 &&
|
Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 &&
|
||||||
notes[0].note === 60,
|
notes[0].note === 60,
|
||||||
@@ -469,13 +471,15 @@ export async function song(t) {
|
|||||||
JSON.stringify(buses),
|
JSON.stringify(buses),
|
||||||
);
|
);
|
||||||
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
|
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
|
||||||
const muted = await waitFor(
|
// Mute closes every send of the track (#350); its own level stays as it is set.
|
||||||
() => json('__gm.song.buses()'),
|
const closed = (list) => {
|
||||||
(list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0,
|
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(
|
check(
|
||||||
'muting a track while it plays silences its bus at once',
|
'muting a track while it plays closes its sends at once',
|
||||||
muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')),
|
closed(muted) && (await json('__gm.song.playing()')),
|
||||||
JSON.stringify(muted),
|
JSON.stringify(muted),
|
||||||
);
|
);
|
||||||
await shot('02-playing');
|
await shot('02-playing');
|
||||||
@@ -603,8 +607,8 @@ export async function song(t) {
|
|||||||
run.owner !== '' &&
|
run.owner !== '' &&
|
||||||
run.clips.length === 1 &&
|
run.clips.length === 1 &&
|
||||||
gameClip?.sound === SOUND &&
|
gameClip?.sound === SOUND &&
|
||||||
gameClip.bus === busOf(tracks[0]) &&
|
gameClip.bus === itemsOf(tracks[0]) &&
|
||||||
gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) &&
|
gameNotes.every((n) => n.bus === itemsOf(tracks[1]) && n.tempo === 120) &&
|
||||||
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
|
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
|
||||||
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
|
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
|
||||||
Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 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',
|
'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 &&
|
fxBus?.fx === true &&
|
||||||
Math.abs(fxBus.latency - frame) < 1e-9 &&
|
Math.abs(fxBus.latency - frame) < 1e-9 &&
|
||||||
fxNote?.bus === busOf(tracks[1]) &&
|
fxNote?.bus === itemsOf(tracks[1]) &&
|
||||||
plainClip?.bus === busOf(tracks[0]) &&
|
plainClip?.bus === itemsOf(tracks[0]) &&
|
||||||
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
|
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
|
||||||
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
|
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
|
||||||
Math.abs(clipStart - plainClip.at) < 1e-6,
|
Math.abs(clipStart - plainClip.at) < 1e-6,
|
||||||
@@ -855,6 +859,123 @@ export async function song(t) {
|
|||||||
JSON.stringify(released),
|
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.
|
// 65j. The step takes back everything it made.
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
Reference in new issue
Block a user