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>
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import type { Graphics } from 'pixi.js';
|
||
import type { Command } from '../../core/commands/Command';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { ConnectCommand } from '../../core/commands/graphCommands';
|
||
import {
|
||
canConnect,
|
||
defAcceptsFrom,
|
||
dropTargetPort,
|
||
firstPortFor,
|
||
type Graph,
|
||
type PortSide,
|
||
type PortDef,
|
||
portOf,
|
||
STATE_IN,
|
||
STATE_OUT,
|
||
} from '../../core/graph';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { Theme, ui } from '../../ui';
|
||
import { nodeSelection } from '../state';
|
||
import { arrowHead, bezier, loopPath } from './EdgeLayer';
|
||
import type { GraphCanvasHost } from './graphHost';
|
||
import { NodePalette } from './NodePalette';
|
||
import type { PaletteEntry } from './paletteEntries';
|
||
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
|
||
|
||
/**
|
||
* Making edges: a drag from a pin, the palette a drag into empty space opens, and a transition
|
||
* armed from a state's menu that waits for the click naming its target.
|
||
*/
|
||
export class GraphWiring {
|
||
private connecting: { from: PortSide; pointer: Point } | null = null;
|
||
/** A finished drag whose palette is still open – drawn like the running one, so the connection
|
||
* being made stays visible while the node type is chosen. */
|
||
private pendingDrop: { from: PortSide; pointer: Point } | null = null;
|
||
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
|
||
* the shape of a drag because it is one in everything but the held button – that way the running
|
||
* preview draws it without a second code path. `release` takes the Escape handler back off. */
|
||
private armed: { from: PortSide; pointer: Point; release: () => void } | null = null;
|
||
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
|
||
private dropTarget: PortSide | null = null;
|
||
/** Whether the last redraw actually stroked the dangling edge. */
|
||
private tempEdgeShown = false;
|
||
|
||
constructor(private readonly host: GraphCanvasHost) {}
|
||
|
||
/** A transition is waiting for its target. */
|
||
get isArmed(): boolean {
|
||
return this.armed !== null;
|
||
}
|
||
|
||
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
|
||
get target(): PortSide | null {
|
||
return this.dropTarget;
|
||
}
|
||
|
||
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
|
||
get edgeShown(): boolean {
|
||
return this.tempEdgeShown;
|
||
}
|
||
|
||
/** A press on a pin: the edge follows the pointer until the button comes up. */
|
||
startDrag(from: PortSide, global: Point): void {
|
||
this.connecting = { from, pointer: this.host.toGraph(global) };
|
||
this.dropTarget = null;
|
||
this.host.redraw();
|
||
}
|
||
|
||
/** Follows the pointer with a drag or an armed transition; false when there is neither. */
|
||
move(global: Point): boolean {
|
||
const g = this.host.toGraph(global);
|
||
if (this.connecting) {
|
||
this.connecting.pointer = g;
|
||
this.dropTarget = this.resolveDrop(global);
|
||
} else if (this.armed) {
|
||
this.armed.pointer = g;
|
||
this.dropTarget = this.armedTarget(global);
|
||
} else return false;
|
||
this.host.redraw();
|
||
return true;
|
||
}
|
||
|
||
/** Ends a drag: onto a port it connects, into empty space it offers the palette. */
|
||
release(global: Point): void {
|
||
const connecting = this.connecting;
|
||
const target = connecting ? (this.dropTarget ?? this.resolveDrop(global)) : null;
|
||
this.connecting = null;
|
||
this.dropTarget = null;
|
||
if (!connecting || !this.host.assetId()) return;
|
||
if (target) this.connect(connecting.from, target);
|
||
else if (!this.host.nodeUnder(global)) this.openPaletteForDrop(connecting, global);
|
||
}
|
||
|
||
private connect(from: PortSide, to: PortSide): void {
|
||
const assetId = this.host.assetId();
|
||
if (assetId) this.host.state.execute(connectSides(assetId, from, to));
|
||
}
|
||
|
||
/**
|
||
* Dropped into empty space: offer the node types this edge could reach, then wire the new node
|
||
* up the same way a drop on its body would.
|
||
*/
|
||
private openPaletteForDrop(drag: { from: PortSide; pointer: Point }, global: Point): void {
|
||
const graph = this.host.asset()?.graph;
|
||
const from = drag.from;
|
||
const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project));
|
||
if (!graph || fromPort === undefined) return;
|
||
const at = this.host.toGraph(global);
|
||
this.pendingDrop = drag;
|
||
const palette = new NodePalette(
|
||
this.host.entries(),
|
||
(entry) => this.addConnectedNode(entry, from, fromPort, at),
|
||
(def) => defAcceptsFrom(def, fromPort, from.isOutput),
|
||
);
|
||
palette.onClose = () => {
|
||
this.pendingDrop = null;
|
||
this.host.redraw();
|
||
};
|
||
palette.open(global.x, global.y);
|
||
}
|
||
|
||
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
|
||
private addConnectedNode(entry: PaletteEntry, from: PortSide, fromPort: PortDef, at: Point): void {
|
||
const assetId = this.host.assetId();
|
||
if (!assetId) return;
|
||
const add = this.host.newNode(entry, at);
|
||
const port = firstPortFor(entry.def, fromPort, from.isOutput);
|
||
const commands: Command[] = [add];
|
||
if (port)
|
||
commands.push(connectSides(assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }));
|
||
if (this.host.state.execute(oneStep(add.label, commands)))
|
||
this.host.state.select(nodeSelection(assetId, [add.node.id]));
|
||
}
|
||
|
||
/**
|
||
* Where the running drag would connect: the port under the pointer, or – dropped on a node's
|
||
* body – the port that node picks for it. Null means the drop does nothing.
|
||
*/
|
||
private resolveDrop(global: Point): PortSide | null {
|
||
const graph = this.host.asset()?.graph;
|
||
const from = this.connecting?.from;
|
||
if (!graph || !from) return null;
|
||
const lookup = graphLookup(this.host.state.project);
|
||
const hit = this.portUnder(global);
|
||
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
|
||
const node = this.host.nodeUnder(global);
|
||
if (!node || this.host.views().get(node)?.collapsed) return null;
|
||
return dropTargetPort(graph, from, node, lookup);
|
||
}
|
||
|
||
private portUnder(global: Point): PortSide | null {
|
||
for (const view of this.host.topDownViews()) {
|
||
const local = view.toLocal(global);
|
||
const hit = view.portAt(local.x, local.y);
|
||
// A ghost is no target, and it must not shadow the node body underneath it either.
|
||
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Begin a transition out of this state; the next click on another one completes it. */
|
||
arm(nodeId: string, pointer: Point): void {
|
||
this.cancel();
|
||
const handlers = ui().focus.globalHandlers;
|
||
const onKey = (event: KeyboardEvent): boolean => {
|
||
if (event.key !== 'Escape' || !this.armed) return false;
|
||
this.cancel();
|
||
this.host.redraw();
|
||
return true;
|
||
};
|
||
handlers.push(onKey);
|
||
this.armed = {
|
||
from: { nodeId, port: STATE_OUT, isOutput: true },
|
||
pointer,
|
||
release: () => {
|
||
const at = handlers.indexOf(onKey);
|
||
if (at >= 0) handlers.splice(at, 1);
|
||
},
|
||
};
|
||
this.dropTarget = null;
|
||
this.host.redraw();
|
||
}
|
||
|
||
cancel(): void {
|
||
this.armed?.release();
|
||
this.armed = null;
|
||
}
|
||
|
||
/** Where an armed transition would land: the state under the pointer, its own source included. */
|
||
private armedTarget(global: Point): PortSide | null {
|
||
if (!this.armed) return null;
|
||
const node = this.host.nodeUnder(global);
|
||
if (!node) return null;
|
||
const view = this.host.views().get(node);
|
||
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
|
||
}
|
||
|
||
/** Finish or abandon an armed transition. True when the click belonged to it. */
|
||
finish(global: Point, connect: boolean): boolean {
|
||
const armed = this.armed;
|
||
if (!armed) return false;
|
||
const target = connect ? this.armedTarget(global) : null;
|
||
this.cancel();
|
||
this.dropTarget = null;
|
||
if (target) this.connect(armed.from, target);
|
||
this.host.redraw();
|
||
return true;
|
||
}
|
||
|
||
/** The edge being made, from its pin to the pointer – or to the port it would land on. */
|
||
draw(g: Graphics, graph: Graph): void {
|
||
const c = Theme.colors;
|
||
const views = this.host.views();
|
||
const target = this.dropTarget;
|
||
g.clear();
|
||
this.tempEdgeShown = false;
|
||
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
|
||
const a = drag && this.anchorPoint(drag.from);
|
||
const home = drag && target?.nodeId === drag.from.nodeId ? views.get(target.nodeId) : undefined;
|
||
if (home?.def.transitionEdges === true) {
|
||
const ring = graph.edges.filter(
|
||
(e) => e.from.node === home.node.id && e.to.node === home.node.id,
|
||
).length;
|
||
const loop = selfLoop(home, ring);
|
||
loopPath(g, loop);
|
||
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||
arrowHead(g, loop.c2, loop.end, c.textMuted);
|
||
this.tempEdgeShown = true;
|
||
} else if (drag && a) {
|
||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||
// Dragged off a state, the line already looks like the transition it is about to become.
|
||
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
|
||
const ends = preview ? [preview.start, preview.end] : [a, b];
|
||
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
|
||
if (preview) {
|
||
const shaft = arrowShaft(from, to);
|
||
g.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
|
||
} else bezier(g, from.x, from.y, to.x, to.y);
|
||
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||
if (preview) arrowHead(g, from, to, c.textMuted);
|
||
this.tempEdgeShown = true;
|
||
}
|
||
}
|
||
|
||
/** Null unless the drag started on a state: only those draw as a straight arrow. */
|
||
private transitionPreviewOf(
|
||
from: PortSide,
|
||
target: PortSide | null,
|
||
pointer: Point,
|
||
): { start: Point; end: Point } | null {
|
||
const views = this.host.views();
|
||
const source = views.get(from.nodeId);
|
||
if (source?.def.transitionEdges !== true) return null;
|
||
const to = target ? views.get(target.nodeId) : undefined;
|
||
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
|
||
}
|
||
|
||
/** A port's centre in graph coordinates. */
|
||
private anchorPoint(side: PortSide | null): Point | null {
|
||
if (!side) return null;
|
||
const view = this.host.views().get(side.nodeId);
|
||
const p = view?.portPosition(side.port, side.isOutput);
|
||
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
|
||
}
|
||
}
|
||
|
||
/** A drag may start at either end; the command wants the output first. */
|
||
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
|
||
const [out, inp] = a.isOutput ? [a, b] : [b, a];
|
||
return new ConnectCommand(
|
||
assetId,
|
||
{ node: out.nodeId, port: out.port },
|
||
{ node: inp.nodeId, port: inp.port },
|
||
);
|
||
}
|