A sound FX or an instrument had a 5-Band-EQ with fixed bands and a Filter with one band. The Parametric EQ holds up to ten bands of any type, and a double-click on the node opens an editor that draws the curve they make together. - The bands are a list on the node (`bands`): type, frequency, gain, Q and slope. A hand-edited project is held to the editor's ranges and to ten bands. - Each band compiles to the existing `pass` stage, so the audio core is unchanged. A peak or a shelf at 0 dB is left out. - The curve is worked out in `parametricEq.ts` with the biquad formulas of the Web Audio spec, so the editor draws what is heard. - The editor is a window that floats over the editor without a backdrop, so the tab's Play stays usable. A double-click adds a peak, a drag moves a band, the wheel turns its Q, a right-click removes it. Fields below the curve set the picked band. It opens in the middle of the screen and moves by its title bar. - A drag or a scrub writes the node as a preview and makes one undo step on release. The panels hear of the preview once a frame, and the window lays itself out only when one of its texts changes. - The area under the curve is filled as one shape for each side of 0 dB. As a single shape it crossed itself and came apart. - The curve works out each band's coefficients once and adds the sum up from the bands. A drag took 295 ms of script time in a profile before and 64 ms after. Decided on my own: - Bands are added by a double-click, so a single click can let go of the picked band without adding one. - The cuts are labelled "Low cut" and "High cut" as in an EQ, not "High-pass" and "Low-pass" as on the Filter. - The bands have no input, so no parameter or Envelope moves them. Tests: - Unit parametric-eq (new) and soundfx: 69 green. Counter-probe with no stages emitted: the compile test red. Counter-probe with one fill shape: the fill test red. - e2e 34 (melektaus-atx): ALL PASSED, 96 checks (3 new). - Counter-probe e2e: no stages emitted, the playing check red. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
15 KiB
TypeScript
243 lines
15 KiB
TypeScript
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||
import { currentNodePalette } from '../graph/NodePalette';
|
||
import { currentEqEditor } from '../soundfx/EqEditor';
|
||
import { rectOf } from '../textProbe';
|
||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||
|
||
/** The node graphs: behaviors, state machines, particles and shaders. */
|
||
export function graphHooks({ state, editor }: HookContext) {
|
||
const { graphEditor } = editor;
|
||
return {
|
||
graph: {
|
||
current: () => graphEditor.currentAssetId,
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||
chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
|
||
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
|
||
/** Each input row as drawn: its name, and the inline value beside it while unconnected. */
|
||
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
|
||
/** Each output row as drawn: while playing, the value it last carried in front of its name. */
|
||
nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId),
|
||
/** Each row's input and output text as cut and placed, in node units: two that overlap collide (#306). */
|
||
rowSpans: (nodeId: string) => graphEditor.canvas.nodeRowSpans(nodeId),
|
||
/** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */
|
||
beads: () => graphEditor.canvas.beadsDrawn(),
|
||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||
/** Bare canvas below every node – where a step right-clicks to open the palette. */
|
||
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
|
||
/**
|
||
* What the open palette lists, in order. No colour check can see a ranking: the rows all
|
||
* look alike, only their order carries the answer (#43).
|
||
*/
|
||
paletteRows: () => currentNodePalette()?.rowsDrawn() ?? null,
|
||
selected: () => state.selectedNodeIds,
|
||
/** Groups and comments as drawn – text and both colours included, since no pixel says them. */
|
||
annotations: () => graphEditor.canvas.annotationsDrawn(),
|
||
/** Where one is grabbed: its title bar, or with `corner` the handle that resizes it. */
|
||
annotationAt: (id: string, corner = false) => graphEditor.canvas.annotationGlobal(id, corner),
|
||
/** The box as drawn on screen – how a check sees whether a fit brought it into view. */
|
||
annotationRect: (id: string) => graphEditor.canvas.annotationRect(id),
|
||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
|
||
/** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */
|
||
edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId),
|
||
/** Halfway along a link on screen, and the links drawn as picked (#289). */
|
||
edgeAt: (edgeId: string) => graphEditor.canvas.edgeGlobal(edgeId),
|
||
pickedEdges: () => graphEditor.canvas.pickedEdgesDrawn(),
|
||
/** The types the node's ports are drawn with, after a shared group had its say (#207). */
|
||
portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId),
|
||
blockFrames: () => graphEditor.canvas.blockFrames(),
|
||
foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput),
|
||
foldMarks: () => graphEditor.canvas.foldMarksDrawn(),
|
||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||
// drawing area.
|
||
center: () => centreOf(graphEditor.canvas),
|
||
snapping: () => graphEditor.canvas.snapEnabled,
|
||
snapButton: () => snapButton(graphEditor.snap),
|
||
zoom: () => graphEditor.canvas.zoomLevel,
|
||
/** Nodes left in the picture, and how often texts, boxes and links were really made (#292). */
|
||
nodesDrawn: () => graphEditor.canvas.nodesDrawn(),
|
||
made: () => graphEditor.canvas.partsMade(),
|
||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||
},
|
||
stateMachine: {
|
||
current: () => editor.stateMachineEditor.currentAssetId,
|
||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
|
||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||
snapButton: () => snapButton(editor.stateMachineEditor.snap),
|
||
/** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||
edgeAt: (edgeId: string) => editor.stateMachineEditor.canvas.edgeGlobal(edgeId),
|
||
pickedEdges: () => editor.stateMachineEditor.canvas.pickedEdgesDrawn(),
|
||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||
transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(edgeId),
|
||
hint: () => editor.stateMachineEditor.canvas.shownHint(),
|
||
/** Names a transition the way the inspector row does, for steps that need one already named. */
|
||
nameTransition: (edgeId: string, name: string) =>
|
||
state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)),
|
||
dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(),
|
||
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
||
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
||
selected: () => state.selectedNodeIds,
|
||
/** The start ring and the live marks as drawn – what no colour check could tell apart. */
|
||
startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId),
|
||
stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(),
|
||
},
|
||
soundfx: {
|
||
current: () => editor.soundFxEditor.currentAssetId,
|
||
info: () => editor.soundFxEditor.shownInfo,
|
||
canvasRect: () => editor.soundFxEditor.canvas.canvasRect(),
|
||
nodeProblem: (nodeId: string) => editor.soundFxEditor.canvas.nodeProblem(nodeId),
|
||
nodeSettings: (nodeId: string) => editor.soundFxEditor.canvas.nodeSettings(nodeId),
|
||
nodeAlpha: (nodeId: string) => editor.soundFxEditor.canvas.nodeAlpha(nodeId),
|
||
/** The header's listening controls: which sound, and whether Play is offered. */
|
||
preview: () => ({
|
||
sound: editor.soundFxEditor.previewSoundId,
|
||
picker: editor.soundFxEditor.soundPicker.text,
|
||
play: editor.soundFxEditor.playButton.enabled,
|
||
}),
|
||
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
||
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
||
/** The header's parameter values, and setting one as its field would. */
|
||
params: () => editor.soundFxEditor.previewValues,
|
||
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
|
||
},
|
||
/** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */
|
||
eq: {
|
||
shown: () => currentEqEditor()?.shownFor ?? null,
|
||
bands: () => currentEqEditor()?.bands() ?? null,
|
||
picked: () => currentEqEditor()?.picked ?? null,
|
||
handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null,
|
||
pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null,
|
||
fields: () => {
|
||
const eq = currentEqEditor();
|
||
return (
|
||
eq && {
|
||
type: eq.typeButton.text,
|
||
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
|
||
gain: eq.gainField.enabled ? eq.gainField.value : null,
|
||
q: eq.qField.enabled ? eq.qField.value : null,
|
||
frequency: eq.frequencyField.value,
|
||
}
|
||
);
|
||
},
|
||
typeAt: () => {
|
||
const eq = currentEqEditor();
|
||
return eq ? centreOf(eq.typeButton) : null;
|
||
},
|
||
closeAt: () => {
|
||
const eq = currentEqEditor();
|
||
return eq ? centreOf(eq.closeButton) : null;
|
||
},
|
||
},
|
||
instrument: {
|
||
current: () => editor.instrumentEditor.currentAssetId,
|
||
info: () => editor.instrumentEditor.shownInfo,
|
||
nodeAt: (nodeId: string) => editor.instrumentEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.instrumentEditor.canvas.nodeLabel(nodeId),
|
||
nodeOutputs: (nodeId: string) => editor.instrumentEditor.canvas.nodeOutputs(nodeId),
|
||
nodeProblem: (nodeId: string) => editor.instrumentEditor.canvas.nodeProblem(nodeId),
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.instrumentEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
canvasRect: () => editor.instrumentEditor.canvas.canvasRect(),
|
||
/**
|
||
* The keys: which make a sound, which are held, which are let go but still heard, where one is,
|
||
* and the computer keyboard's octave.
|
||
*/
|
||
keys: () => ({
|
||
sounding: editor.instrumentEditor.soundingNotes(),
|
||
held: editor.instrumentEditor.keyboard.heldNotes,
|
||
ringing: editor.instrumentEditor.keyboard.ringingNotes,
|
||
octave: editor.instrumentEditor.keyboard.bottomOctave,
|
||
}),
|
||
keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note),
|
||
stopAt: () => centreOf(editor.instrumentEditor.stopButton),
|
||
presetAt: () => centreOf(editor.instrumentEditor.presetButton),
|
||
randomizeAt: () => centreOf(editor.instrumentEditor.randomizeButton),
|
||
mutateAt: () => centreOf(editor.instrumentEditor.mutateButton),
|
||
auditionAt: () => ({
|
||
...centreOf(editor.instrumentEditor.auditionButton),
|
||
toggled: editor.instrumentEditor.auditionButton.toggled,
|
||
}),
|
||
params: () => editor.instrumentEditor.previewValues,
|
||
setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value),
|
||
/** Starts a note as a key does, held until stopped; gives its voice. */
|
||
play: (note: number) => editor.instrumentEditor.playNote(note),
|
||
},
|
||
particles: {
|
||
current: () => editor.particlesEditor.currentAssetId,
|
||
info: () => editor.particlesEditor.shownInfo,
|
||
snapButton: () => snapButton(editor.particlesEditor.snap),
|
||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId),
|
||
nodeProblem: (nodeId: string) => editor.particlesEditor.canvas.nodeProblem(nodeId),
|
||
canvasRect: () => editor.particlesEditor.canvas.canvasRect(),
|
||
emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(),
|
||
center: () => centreOf(editor.particlesEditor.canvas),
|
||
/** The panel beside the graph: how many particles it draws and which second it stands at. */
|
||
preview: () => ({
|
||
count: editor.particlesEditor.preview.count,
|
||
time: Math.round(editor.particlesEditor.preview.time * 100) / 100,
|
||
holding: editor.particlesEditor.preview.holding,
|
||
}),
|
||
previewRect: () => rectOf(editor.particlesEditor.preview),
|
||
/** Hold button, time readout and slider as they are shown, not as the preview holds them. */
|
||
controls: () => editor.particlesEditor.controls,
|
||
splitterAt: () => centreOf(editor.particlesEditor.splitter),
|
||
sliderAt: () => centreOf(editor.particlesEditor.slider),
|
||
/** Jumps the preview to a second – exact, because a particle is a function of its age. */
|
||
hold: (at: number | null) => editor.particlesEditor.preview.hold(at),
|
||
},
|
||
shader: {
|
||
current: () => editor.shaderEditor.currentAssetId,
|
||
info: () => editor.shaderEditor.shownInfo,
|
||
nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId),
|
||
nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId),
|
||
nodeSettings: (nodeId: string) => editor.shaderEditor.canvas.nodeSettings(nodeId),
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.shaderEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
/** What an arithmetic node's result is drawn as: the type that really comes out (#219). */
|
||
portTypes: (nodeId: string) => editor.shaderEditor.canvas.portTypes(nodeId),
|
||
edgeColor: (edgeId: string) => editor.shaderEditor.canvas.edgeColor(edgeId),
|
||
canvasRect: () => editor.shaderEditor.canvas.canvasRect(),
|
||
emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(),
|
||
center: () => centreOf(editor.shaderEditor.canvas),
|
||
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
||
snapButton: () => snapButton(editor.shaderEditor.snap),
|
||
/** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */
|
||
preview: () => ({
|
||
...editor.shaderEditor.controls,
|
||
holding: editor.shaderEditor.preview.holding,
|
||
seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100,
|
||
}),
|
||
previewRect: () => rectOf(editor.shaderEditor.preview),
|
||
/** Where the picture sits inside the panel, in screen pixels. */
|
||
pictureRect: () => editor.shaderEditor.preview.pictureRect,
|
||
sliderAt: (key: string) => {
|
||
const slider = editor.shaderEditor.sliderOf(key);
|
||
return slider ? rectOf(slider) : null;
|
||
},
|
||
splitterAt: () => centreOf(editor.shaderEditor.splitter),
|
||
hold: (at: number | null) => editor.shaderEditor.hold(at),
|
||
},
|
||
};
|
||
}
|