Draw the EQ's curve in its node and a spectrum behind it (#375, #374)

Both were asked for in #367 "for later". The node of a Parametric EQ
now shows its curve in small, and its tab shows what passes the node
while it is seen.

#375, the curve in the node:

- NodeDef has an optional `picture`: a height, and shapes made from
  the settings as plain point lists (guide, area, line). It is
  general, the Parametric EQ is its first user.
- NodeView draws it in a box below the rows, again only when the box
  or the settings change. A drag in the EQ tab changes them, so the
  node follows it live.
- Without a band the box stays, flat on its 0 dB line, so the node
  does not grow at the first band.
- The axes of the curve, `eqCurvePoints` and `fillShapes` move from
  editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node
  draw the sum from the same function.

#374, the spectrum:

- A node marked `tapped` gets a `tap` stage in front of its own
  stages and one behind them when compiled, in a sound FX and in an
  instrument alike. Its id is asset and node, since a copied asset
  keeps its node ids.
- A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts)
  holds the tap of every voice and bus while it plays.
- While the EQ tab is seen, in the tab row or in a window, the taps
  of that node feed one analyser per side (8192 points). Nothing is
  made before a tap of the node plays, so no AudioContext is opened
  early.
- The tab draws both behind the curve, on a scale of 0 to -96 dBFS:
  the sound coming in faint and grey, the sound going on in the
  colour of a sound port. The analyser smooths it, and once the
  sound has gone the sum still hands it silence, so it falls away.
- Once the tab is not seen, Editor.tick lets the analysers go, like
  the meters of the Song tab.
- A tap does nothing to the sound, so `stats().last.stages` leaves it
  out, and a bypassed EQ keeps its taps.

Decided on my own:

- Every sound passing the node counts: Play of the sound FX tab, an
  instrument's keys, a song, a run. The issue proposed that.
- The spectrum has no labels of its own. Its scale is in the wiki.

Tests:

  Gate (melektaus-atx)     275 files, 4247 tests (11 new), EXIT 0
  e2e 34 (melektaus-atx)   ALL PASSED, 98 checks (2 new), twice

Counter-checks, each put back by copy and compared with cmp:

- The node's curve drawn without its bands: 2 unit tests fail.
- The spectrum without the loudest bin between two points: 1 fails.
- SpectrumTaps.stop leaving the taps linked: 1 fails.
- Taps that never feed the analyser: e2e 34 fails in exactly the
  spectrum check (frames 0). Run in the container only.

The screenshots of the second run were looked at: the blip as one
peak behind the curve, and the dip at 4 kHz in the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 15:58:53 +02:00
1 parent 25084f8576
commit 687377682c
19 files changed
+802 -99

No files matched your search

+52 -2
View File
@@ -11,6 +11,7 @@ import {
type NodeCategory,
type NodeDef,
nodeDef,
type NodePicture,
type PortDef,
type PortType,
type Value,
@@ -125,6 +126,8 @@ export interface PortHit {
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
export class NodeView extends Widget {
private readonly box = new Graphics();
/** What the node draws of its settings below its rows (#375). */
private readonly picture = new Graphics();
private readonly ports = new Graphics();
private readonly texts: Text[] = [];
/** The input and output text of each port row, among `texts`. */
@@ -159,6 +162,7 @@ export class NodeView extends Widget {
*/
private textsOf = '';
private drawnOf = '';
private pictureOf = '';
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
@@ -178,8 +182,9 @@ export class NodeView extends Widget {
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
// nodes were a few hundred draw calls. In the batch they share one.
this.box.context.batchMode = 'batch';
this.picture.context.batchMode = 'batch';
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.ports);
this.addChild(this.box, this.picture, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -250,7 +255,10 @@ export class NodeView extends Widget {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
const picture = this.shownPicture();
const height = this.collapsed
? TITLE_HEIGHT
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
// Sized before the texts: output labels are placed against the right edge.
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
const texts = this.textSignature();
@@ -387,6 +395,47 @@ export class NodeView extends Widget {
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
}
/** Below the last port row. */
private rowsEnd(): number {
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
}
/** The node's picture, while the node is open. */
private shownPicture(): NodePicture | null {
return (!this.collapsed && this.def.picture) || null;
}
/** Where the picture sits, in node units. */
private pictureBox(): { x: number; y: number; w: number; h: number } | null {
const picture = this.shownPicture();
return picture && { x: PAD, y: this.rowsEnd() + PAD / 2, w: this.w - PAD * 2, h: picture.height };
}
/** Drawn again when the settings or the size have changed: a drag in the EQ's tab changes them. */
private drawPicture(): void {
const picture = this.shownPicture();
const box = this.pictureBox();
const drawn = picture && box ? JSON.stringify([box, this.node.params]) : '';
if (drawn === this.pictureOf) return;
this.pictureOf = drawn;
this.picture.clear();
if (!picture || !box) return;
const c = Theme.colors;
this.picture.position.set(box.x, box.y);
this.picture.rect(0, 0, box.w, box.h).fill(c.graphBackground).stroke({ color: c.nodeBorder, width: 1 });
for (const { kind, points } of picture.shapes(this.node.params, box.w)) {
if (kind === 'area') {
this.picture.poly(points).fill({ color: c.accent, alpha: 0.18 });
continue;
}
this.picture.moveTo(points[0]!, points[1]!);
for (let i = 2; i < points.length; i += 2) this.picture.lineTo(points[i]!, points[i + 1]!);
this.picture.stroke(
kind === 'guide' ? { color: c.textMuted, width: 1, alpha: 0.5 } : { color: c.accent, width: 1.5 },
);
}
}
/** Local position of a port's centre. */
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
const list = isOutput ? this.def.outputs : this.def.inputs;
@@ -490,6 +539,7 @@ export class NodeView extends Widget {
}
override redraw(): void {
this.drawPicture();
const drawn = this.drawSignature();
if (drawn === this.drawnOf) return;
this.drawnOf = drawn;