Files
feuerwerk/src/editor/debug/graphHooks.ts
T
adminandClaude Opus 5.5 1f13794b0c
game-maker / gate (push) Successful in 4m17s
game-maker / suite (push) Successful in 16m14s
Shape a sound with a Parametric EQ and its own curve editor (#367)
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>
2026-10-09 12:00:43 +02:00

243 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
},
};
}