Without an envelope a note set in hard and broke off 10 ms after its end. An instrument now has an Envelope node with attack, decay, sustain and release. On the way of the sound it is the volume. Its output "value" moves a number of the note with the same curve, from "From" to "To", for a filter sweep among others. A new Filter node gives that sweep something to move. - Envelope (instrument only): attack a straight line, decay and release ease out and are 60 dB of the way at their time. A release takes 10 ms at least. Defaults 0.005 / 0.1 / 1 / 0.1. - The end of a note goes to the Envelope instead of the 10 ms fade. A Gate or Loop Sample under it plays on through the release, "Loop, then release" leaves its loop too, and the voice ends with the release. A One-shot under an Envelope of its own plays through. - A note ending before attack and decay releases from the level it has reached. Chrome holds it with cancelAndHoldAtTime, Firefox gets the level worked out from the shape. - New port type "envelope" (coral). It fits only a number input that says it can follow one (PortDef.followsEnvelope): Filter cutoff and resonance, Pitch, Gain, the EQ bands and Mix. Not the Delay, not the Envelope's own numbers. portsMeet() decides it for ConnectCommand, the drag and the drawn edges. - On the audio thread every Envelope of a note is one ConstantSourceNode. A number it moves gets the curve through a WaveShaper, in equal ratios for a cutoff and a gain. A number an Envelope moves is no longer reached by a parameter. - Filter (sound FX and instrument): low-, high- or band-pass, cutoff in Hz, resonance 0 to 1 as a Q. A low- or high-pass is flat at 0 and rises 20 dB at 1, a band-pass goes from Q 0.7 to 20. - Stop sound with "let ring" lets a note with an Envelope fall away in its release. Without it, and with Stop in the tab, it is cut. - The same note again during a release is a voice of its own. - The Envelope's numbers are set when the note starts. They can come from velocity or a parameter, later changes do not bend the note. Tests: - envelope.test.ts (new, 12): curve, ratios, Filter Q, compiling an Envelope, its value into each number, a Delay refused - sound-library +7: attack and decay on the clock, release from the level reached, One-shot held, Stop sound with and without let ring, a cutoff and a pitch moved by the curve - e2e 34: ALL PASSED, 50 checks (+2). Offline in Chrome the curve rendered 0.5 / 1 / 0.5005 at 0.05 / 0.1 / 0.3 s, released at 0.5 s it was 0.0158 at 0.65 s and 0.0005 at 0.8 s, released mid-attack it fell from 0.5 to 0.05 in 0.1 s. A Gain moved 0.25 to 1 was 0.5 halfway. A looped note of 0.2 s with a release of 1 s was gone after 1.32 s. - Five counter-checks (release end, One-shot hold, the follows check, ratios, pitch sum) failed 3, 1, 1, 2 and 2 tests. - Gate on melektaus-atx: CHECK EXIT 0, 245 files, 3812 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
|
||
import type { GraphLookup } from '../graph/lookup';
|
||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
|
||
import { nodeDef, portsMeet, takesManyEdges } from '../graph/NodeRegistry';
|
||
import { graphLookup } from '../model/graphLookup';
|
||
import { newId } from '../ids';
|
||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import type { Project } from '../model/Project';
|
||
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||
import { type Command, CommandError } from './Command';
|
||
import { requireEdge, requireGraph, requireNode, requireNodeDef } from './require';
|
||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||
|
||
export class AddNodeCommand implements Command {
|
||
readonly label: string;
|
||
readonly node: GraphNode;
|
||
/** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */
|
||
readonly closing: GraphNode | null;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
type: string,
|
||
x: number,
|
||
y: number,
|
||
params: PropertyBag = {},
|
||
) {
|
||
// An unknown type is rejected in `execute`: constructors run outside the editor's error
|
||
// handling, so throwing here would crash instead of showing a notice.
|
||
const def = nodeDef(type);
|
||
this.node = {
|
||
id: newId(),
|
||
type,
|
||
x: Math.round(x),
|
||
y: Math.round(y),
|
||
params: { ...defaultsOf(def?.params ?? []), ...params },
|
||
};
|
||
const opens = def?.opens;
|
||
this.closing = opens
|
||
? {
|
||
id: newId(),
|
||
type: opens.type,
|
||
x: this.node.x + opens.dx,
|
||
y: this.node.y,
|
||
params: defaultsOf(nodeDef(opens.type)?.params ?? []),
|
||
}
|
||
: null;
|
||
this.label = def ? `Add ${def.label}` : `Add ${type}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
requireNodeDef(this.node.type);
|
||
const graph = requireGraph(project, this.assetId);
|
||
// The two terminals give the behavior its signature; a second one of either would make
|
||
// "which ports does this behavior have" ambiguous.
|
||
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||
throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`);
|
||
graph.nodes.push(this.node);
|
||
if (this.closing) graph.nodes.push(this.closing);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
graph.nodes = removeById(graph.nodes, this.node.id);
|
||
if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.id);
|
||
}
|
||
}
|
||
|
||
/** Removes a node and every edge touching it. */
|
||
export class RemoveNodeCommand implements Command {
|
||
readonly label = 'Remove node';
|
||
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
|
||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
|
||
const touching = edgesOfNode(graph, this.nodeId);
|
||
this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) };
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const graph = requireGraph(project, this.assetId);
|
||
restoreAt(graph.nodes, [this.removed.node]);
|
||
restoreAt(graph.edges, this.removed.edges);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The previous position is read at execution time, so a caller that previewed the drag must restore
|
||
* the node before executing.
|
||
*/
|
||
export class MoveNodeCommand implements Command {
|
||
readonly label = 'Move node';
|
||
private previous = { x: 0, y: 0 };
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly x: number,
|
||
private readonly y: number,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
this.previous = { x: node.x, y: node.y };
|
||
node.x = Math.round(this.x);
|
||
node.y = Math.round(this.y);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
node.x = this.previous.x;
|
||
node.y = this.previous.y;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets a node param or the inline value of an unconnected data input. A param can decide how many
|
||
* ports the node has, so this takes the edges of ports the new value does away with: an edge on a
|
||
* port that is gone draws as nothing but survives every save, and comes back to life if the port
|
||
* ever returns.
|
||
*/
|
||
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||
private orphans: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
key: string,
|
||
private readonly value: PropertyValue,
|
||
) {
|
||
super(key, `Set ${key}`);
|
||
}
|
||
|
||
override execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const lookup = graphLookup(project);
|
||
// A param can reshape another node too – a Find's mode decides its Match's outputs (#259).
|
||
const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup)));
|
||
super.execute(project);
|
||
this.orphans = removeWhere(
|
||
graph.edges,
|
||
(e) =>
|
||
(held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) &&
|
||
!edgeHolds(graph, e, lookup),
|
||
);
|
||
}
|
||
|
||
override undo(project: Project): void {
|
||
super.undo(project);
|
||
restoreAt(requireGraph(project, this.assetId).edges, this.orphans);
|
||
}
|
||
|
||
protected override resolveTarget(project: Project): GraphNode {
|
||
return requireNode(project, this.assetId, this.nodeId);
|
||
}
|
||
|
||
protected override bagOf(node: GraphNode): PropertyBag {
|
||
return node.params;
|
||
}
|
||
|
||
protected override computeNext(node: GraphNode): PropertyValue {
|
||
const param = requireNodeDef(node.type).params.find((p) => p.key === this.key);
|
||
// Inline input values are stored as-is; declared params are coerced against their definition.
|
||
return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||
}
|
||
}
|
||
|
||
/** Whether both ends of an edge name a port their node has; a node of unknown type is not judged. */
|
||
function edgeHolds(graph: Graph, edge: GraphEdge, lookup: GraphLookup): boolean {
|
||
const has = (end: GraphEdge['from'], side: 'inputs' | 'outputs') => {
|
||
const node = findNode(graph, end.node);
|
||
const def = node && resolveDef(node, graph, lookup);
|
||
return !def || def[side].some((p) => p.key === end.port);
|
||
};
|
||
return has(edge.from, 'outputs') && has(edge.to, 'inputs');
|
||
}
|
||
|
||
/** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */
|
||
export class ConnectCommand implements Command {
|
||
readonly label = 'Connect';
|
||
readonly edge: GraphEdge;
|
||
private replaced: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
from: { node: string; port: string },
|
||
to: { node: string; port: string },
|
||
) {
|
||
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const fromNode = findNode(graph, this.edge.from.node);
|
||
const toNode = findNode(graph, this.edge.to.node);
|
||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||
const lookup = graphLookup(project);
|
||
const fromDef = resolveDef(fromNode, graph, lookup);
|
||
// A transition back into its own state re-enters it; any other edge onto its own node is a loop.
|
||
if (fromNode === toNode && fromDef?.transitionEdges !== true)
|
||
throw new CommandError('A node cannot connect to itself');
|
||
const out = fromDef?.outputs.find((p) => p.key === this.edge.from.port);
|
||
const toDef = resolveDef(toNode, graph, lookup);
|
||
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
|
||
if (!out || !inp) throw new CommandError('Port not found');
|
||
if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||
if (graph.edges.some((e) => sameEnds(e, this.edge))) {
|
||
// Transitions are told apart by name, so a further one between the same states gets its own.
|
||
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
|
||
throw new CommandError('Already connected');
|
||
this.edge.label = nextTransitionName(graph, this.edge);
|
||
}
|
||
const occupied = takesManyEdges(inp) ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
|
||
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
|
||
graph.edges.push(this.edge);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
graph.edges = removeById(graph.edges, this.edge.id);
|
||
restoreAt(graph.edges, this.replaced);
|
||
}
|
||
}
|
||
|
||
/** Folding is drawing only: the edge keeps carrying its value either way. */
|
||
export class FoldEdgeCommand implements Command {
|
||
readonly label: string;
|
||
private previous = false;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
private readonly folded: boolean,
|
||
) {
|
||
this.label = folded ? 'Hide link' : 'Show link';
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||
this.previous = edge.folded === true;
|
||
this.apply(edge, this.folded);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
|
||
}
|
||
|
||
private apply(edge: GraphEdge, folded: boolean): void {
|
||
if (folded) edge.folded = true;
|
||
else delete edge.folded;
|
||
}
|
||
}
|
||
|
||
/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */
|
||
export class NameTransitionCommand implements Command {
|
||
readonly label = 'Name transition';
|
||
private previous = '';
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
private readonly name: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||
const name = this.name.trim();
|
||
// Between the same two states the name is all that tells transitions apart.
|
||
if (parallelTransitions(requireGraph(project, this.assetId), edge).some((e) => (e.label ?? '') === name))
|
||
throw new CommandError(
|
||
name
|
||
? `Another transition between these states is already called "${name}"`
|
||
: 'Another transition between these states is already unnamed',
|
||
);
|
||
this.previous = edge.label ?? '';
|
||
this.apply(edge, name);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
|
||
}
|
||
|
||
private apply(edge: GraphEdge, name: string): void {
|
||
if (name) edge.label = name;
|
||
else delete edge.label;
|
||
}
|
||
}
|
||
|
||
/** One undo step for the whole selection; every node is found before any of them changes. */
|
||
export class CollapseNodesCommand implements Command {
|
||
readonly label: string;
|
||
private previous: boolean[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeIds: readonly string[],
|
||
private readonly collapsed: boolean,
|
||
) {
|
||
const noun = nodeIds.length === 1 ? 'node' : 'nodes';
|
||
this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id));
|
||
this.previous = nodes.map((n) => n.collapsed === true);
|
||
for (const node of nodes) setCollapsed(node, this.collapsed);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!));
|
||
}
|
||
}
|
||
|
||
function setCollapsed(node: GraphNode, collapsed: boolean): void {
|
||
if (collapsed) node.collapsed = true;
|
||
else delete node.collapsed;
|
||
}
|
||
|
||
export class DisconnectCommand implements Command {
|
||
readonly label = 'Disconnect';
|
||
private removed: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId);
|
||
if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
restoreAt(requireGraph(project, this.assetId).edges, this.removed);
|
||
}
|
||
}
|
||
|
||
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
|
||
return (
|
||
a.from.node === b.from.node &&
|
||
a.from.port === b.from.port &&
|
||
a.to.node === b.to.node &&
|
||
a.to.port === b.to.port
|
||
);
|
||
}
|