Edit a Parametric EQ in its node, let the wheel only zoom graphs (#381)

The small curve below the EQ node's rows (#375) only showed the bands.
Like the Compressor since #380, the node now shows the editor of its
tab between its inputs and its output, and is worked right there. A
double-click on the node still opens the same editor in a tab.

Two wishes for the nodes with a panel came on top and share the same
files, so they are in this commit as well: the numbers below the
Compressor's knobs read as plain labels (#382), and the wheel over a
node zooms the graph instead of turning a value (#383).

#381, the EQ in its node:
- EqPanel holds the curve, the band's numbers and the help line, taken
  out of EqEditor. The tab puts its one panel under a header, each EQ
  node has a panel of its own at 720 x 360 (EqFace).
- A press on the curve neither picks nor moves the node. A
  double-click on the curve adds a band, one on the title or the edge
  opens the tab.
- In the node a press on the Type or Slope button stays with the
  button, so the node is not picked or moved.
- The spectrum runs in the node while it is in the picture. The tab
  lets go of the tap once, like the Compressor's, and Editor.tick lets
  every unseen tab and graph go before any seen one listens.
- The count of bands moved from the tab's header into the band row,
  so the node shows it as well.
- The node picture of #375 is gone with its only user: NodePicture,
  EQ_PICTURE and NodeView's drawing of it.

#382, the numbers below the Compressor's knobs in its node:
- A Knob with typed: false shows its number as a centred Label without
  a box, in the panel's ink. The ratio reads 4:1 (ratioText).
- The label is as high as its text and sits up in the dial's open
  bottom, right under the ends of the scale.
- Label takes a colour of its own.

#383, the wheel in a graph:
- GraphCanvas takes the wheel on the way down and stops it there. It
  zooms over a node too, and no knob or curve in a node sees it.
- The tabs keep their wheel: a knob's step, a band's Q and the zoom
  of the EQ's dB.

Found on the way:
- A Menu opened below a button in the zoomed graph would have been
  placed by the button's own height. openBelow now takes the bottom
  edge as drawn.
- The band row did not lay out its labels after a text change when
  its own size stayed. In the tab a later resize hid it, in the node
  "Band 1" and the count were missing. The row now lays out itself.
- Handle and point positions for the checks were unscaled. They now
  go through toGlobal and hold at the graph's zoom.

Tests:
- Unit parametric-eq: 16 tests. The #375 tests now check
  eqCurvePoints and eqRangeOf directly. compressor-view: ratioText.
- e2e 34 (atx): ALL PASSED, 125 checks (3 new: the EQ panel and its
  ports, editing in the node, the spectrum in the node with the tab
  open). The Compressor node's check reads its numbers as labels and
  8:1. Both node checks now expect the wheel to zoom the graph and
  leave the ratio, the Q and the dB zoom alone.
- The unseen-tab check now leaves for the Scene tab, since the node
  in the graph listens to the same tap.
- Counter-check: the tab letting go of the tap every unseen frame
  stalls the node's spectrum with the tab open (1 frame against 22,
  probe on stein2).
- Gate (atx): CHECK EXIT 0, 283 files, 4357 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 12:40:11 +02:00
1 parent 1f2b64fb4c
commit 79a04e6764
21 files changed
+1008 -795

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1953 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1956 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
-17
View File
@@ -111,21 +111,6 @@ export interface DerivedShape {
inOrder?: true;
}
/**
* A small picture a node draws below its rows (#375), as wide as the node and `height` tall. Its
* shapes are flat point lists `[x0, y0, x1, y1, …]` in that box, y down: a `guide` is a faint line, an
* `area` is filled, a `line` is drawn strong.
*/
export interface NodePicture {
height: number;
shapes(params: PropertyBag, w: number): PictureShape[];
}
export interface PictureShape {
kind: 'guide' | 'area' | 'line';
points: number[];
}
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
export interface NodeDef {
type: string;
@@ -150,8 +135,6 @@ export interface NodeDef {
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
dimmed?(params: PropertyBag): boolean;
/** What the node shows of its settings below its rows, drawn rather than written (#375). */
picture?: NodePicture;
/**
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
+2 -3
View File
@@ -16,7 +16,7 @@ import {
toneCutoff,
} from './saturation';
import { PHASER_STAGES } from './phaser';
import { EQ_PICTURE, eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -630,11 +630,10 @@ export const SoundFxParametricEq: SoundFxNodeDef = effect(
type: 'SoundFxParametricEq',
label: 'Parametric EQ',
category: 'sound',
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve, here in small. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and anywhere else to zoom the dB the curve shows, here as well. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the editor is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve and edited right in the node: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and right-click it to remove it. Double-click the node's title to open the same in a tab, where the wheel anywhere else zooms the dB the curve shows. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
inputs: [audioIn],
outputs: [audioOut],
params: [],
picture: EQ_PICTURE,
tapped: true,
emit: (sounds, _amounts, settings) =>
withStages(
-26
View File
@@ -3,7 +3,6 @@
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
* editor draws what is heard.
*/
import type { NodePicture, PictureShape } from '../graph/NodeRegistry';
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
import type { FxPassFilter } from './nodes';
@@ -294,28 +293,3 @@ export function fillShapes(xs: readonly number[], ys: readonly number[], zero: n
shapes.push([...shape, xs.at(-1)!, zero]);
return shapes;
}
/** How tall the curve is in the node, and through how many points it is drawn there (#375). */
const PICTURE_HEIGHT = 60;
const PICTURE_STEPS = 96;
/** The sum of the bands in the node (#375): the 0 dB line, the area to the curve, and the curve. */
function eqPictureShapes(bands: readonly EqBand[], area: EqArea): PictureShape[] {
const { xs, sum } = eqCurvePoints(bands, area, PICTURE_STEPS);
const zero = dbToY(0, area);
return [
{ kind: 'guide', points: [0, zero, area.w, zero] },
...fillShapes(xs, sum, zero).map((points) => ({ kind: 'area' as const, points })),
{ kind: 'line', points: xs.flatMap((x, i) => [x, sum[i]!]) },
];
}
/**
* Without a band the box stays, with its 0 dB line: the node does not jump at the first one. It shows
* the dB the tab is zoomed to.
*/
export const EQ_PICTURE: NodePicture = {
height: PICTURE_HEIGHT,
shapes: (params, w) =>
eqPictureShapes(eqBandsOf(params), { w, h: PICTURE_HEIGHT, range: eqRangeOf(params) }),
};
+4 -4
View File
@@ -1328,16 +1328,16 @@ export class Editor implements PlayHost {
if (this.shows('song')) this.songEditor.tick();
else this.songEditor.hideMeters();
if (this.shows('sound')) this.soundEditor.tick();
if (this.shows('eq')) this.eqEditor.tick();
else this.eqEditor.hideSpectrum();
// A Compressor's tab and its node (#380) listen to the same taps: what is not seen lets go of
// them first, so it cannot take them from what is.
// The tabs of an EQ or a Compressor and their nodes (#380, #381) listen to the same taps: what
// is not seen lets go of them first, so it cannot take them from what is.
const faces = [
['soundfx', this.soundFxEditor.canvas],
['instrument', this.instrumentEditor.canvas],
] as const;
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
if (this.shows('eq')) this.eqEditor.tick();
if (this.shows('compressor')) this.compressorEditor.tick();
this.facesMoving = false;
for (const [kind, canvas] of faces)
+71 -29
View File
@@ -1,8 +1,10 @@
import type { Container } from 'pixi.js';
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { Widget } from '../../ui';
import { currentNodePalette } from '../graph/NodePalette';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { EqPanel } from '../soundfx/EqPanel';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -125,31 +127,54 @@ export function graphHooks({ state, editor }: HookContext) {
/** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */
eq: {
shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null),
bands: () => editor.eqEditor.bands(),
picked: () => editor.eqEditor.picked,
bands: () => editor.eqEditor.panel.bands(),
picked: () => editor.eqEditor.panel.picked,
/** The dB the curve is zoomed to, above and below 0 (#376). */
range: () => editor.eqEditor.range(),
range: () => editor.eqEditor.panel.range(),
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
zoomShown: () => editor.eqEditor.curve.zoomShown,
handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
fields: () => {
const eq = editor.eqEditor;
return {
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: () => centreOf(editor.eqEditor.typeButton),
zoomShown: () => editor.eqEditor.panel.curve.zoomShown,
handleAt: (index: number) => editor.eqEditor.panel.curve.handleGlobal(index),
pointAt: (hz: number, db: number) => editor.eqEditor.panel.curve.pointGlobal(hz, db),
fields: () => eqFields(editor.eqEditor.panel),
typeAt: () => centreOf(editor.eqEditor.panel.typeButton),
/**
* The spectra drawn behind the curve (#374): how many, the loudest bin in front of the node and
* behind it in the last one, and the taps – which node is listened to, how many play, how many
* of them feed an analyser.
*/
spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }),
spectrum: () => ({ ...editor.eqEditor.panel.curve.seen, taps: soundLibrary.spectrumTaps() }),
/**
* The panel a Parametric EQ node shows in the sound FX graph (#381): whether it is shown and
* where, the node's ports, its bands and fields, where to aim on its curve, and its spectra.
*/
panel: (nodeId: string) => {
const canvas = editor.soundFxEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof EqPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
curve: screenRect(panel.curve),
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
bands: panel.bands(),
picked: panel.picked,
fields: eqFields(panel),
handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)),
type: screenRect(panel.typeButton),
spectrum: panel.curve.seen,
};
},
/** Where a frequency and a gain are on the curve of a node's panel, on screen. */
panelPointAt: (nodeId: string, hz: number, db: number) => {
const panel = editor.soundFxEditor.canvas.faceOf(nodeId)?.widget;
return panel instanceof EqPanel ? panel.curve.pointGlobal(hz, db) : null;
},
},
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
compressor: {
@@ -160,7 +185,7 @@ export function graphHooks({ state, editor }: HookContext) {
Object.fromEntries(
Object.entries(editor.compressorEditor.knobs).map(([key, knob]) => [
key,
{ value: knob.value, enabled: knob.enabled, field: knob.field.value },
{ value: knob.value, enabled: knob.enabled, field: knob.shown },
]),
),
dialAt: (key: keyof typeof editor.compressorEditor.knobs) =>
@@ -194,18 +219,13 @@ export function graphHooks({ state, editor }: HookContext) {
const canvas = editor.soundFxEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof CompressorPanel)) return null;
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
return {
shown: seen(panel),
shown: shownOnScreen(panel),
/** On screen, at the graph's zoom; `size` is the panel's own. */
rect: (() => {
const from = panel.toGlobal({ x: 0, y: 0 });
const to = panel.toGlobal({ x: panel.w, y: panel.h });
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
})(),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
@@ -213,9 +233,8 @@ export function graphHooks({ state, editor }: HookContext) {
dials: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
),
fields: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [key, centreOf(knob.field)]),
),
/** The numbers below the knobs, as they read (#382). */
numbers: Object.fromEntries(Object.entries(panel.knobs).map(([key, knob]) => [key, knob.shown])),
needle: { shown: panel.meter.shown, target: panel.meter.target },
leds: panel.leds.litCount,
seen: panel.seen,
@@ -319,3 +338,26 @@ export function graphHooks({ state, editor }: HookContext) {
},
};
}
/** Whether a widget and all it sits in are visible. */
function shownOnScreen(node: Container | null): boolean {
return !node || (node.visible && shownOnScreen(node.parent));
}
/** A widget's box on screen, scaled with whatever it sits in – a node at the graph's zoom. */
function screenRect(widget: Widget): { x: number; y: number; w: number; h: number } {
const from = widget.toGlobal({ x: 0, y: 0 });
const to = widget.toGlobal({ x: widget.w, y: widget.h });
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
}
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
function eqFields(panel: EqPanel) {
return {
type: panel.typeButton.text,
slope: panel.slopeButton.enabled ? panel.slopeButton.text : null,
gain: panel.gainField.enabled ? panel.gainField.value : null,
q: panel.qField.enabled ? panel.qField.value : null,
frequency: panel.frequencyField.value,
};
}
+19 -12
View File
@@ -208,18 +208,25 @@ export class GraphCanvas extends Widget {
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
const at = this.toLocal(e.global);
this.tween.change(() => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
at,
zoomFactor(e.deltaY),
));
this.applyView();
});
e.preventDefault();
});
// The wheel zooms the graph wherever it turns, over a node too: taken on the way down, before a
// knob or curve in a node could turn a value while the graph is zoomed (#383).
this.addEventListener(
'wheel',
(e: FederatedWheelEvent) => {
e.stopPropagation();
const at = this.toLocal(e.global);
this.tween.change(() => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
at,
zoomFactor(e.deltaY),
));
this.applyView();
});
e.preventDefault();
},
{ capture: true },
);
// A drag can end anywhere; without this the preview would outlive it.
onDragChange(() => {
+2 -45
View File
@@ -11,7 +11,6 @@ import {
type NodeCategory,
type NodeDef,
nodeDef,
type NodePicture,
type PortDef,
type PortType,
type Value,
@@ -129,8 +128,6 @@ 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`. */
@@ -165,7 +162,6 @@ export class NodeView extends Widget {
*/
private textsOf = '';
private drawnOf = '';
private pictureOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** How often the texts and the box were really made, for the checks. */
@@ -189,9 +185,8 @@ 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.picture, this.ports);
this.addChild(this.box, this.ports);
if (face) this.addChild(face.widget);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -268,14 +263,13 @@ 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 picture = this.shownPicture();
const face = this.shownFace();
if (face) this.sides = this.faceSides();
const height = this.collapsed
? TITLE_HEIGHT
: face
? this.headerHeight() + this.faceSize().h + PAD
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
: this.rowsEnd() + PAD;
// Sized before the texts: output labels are placed against the right edge.
this.resize(
this.collapsed
@@ -459,42 +453,6 @@ export class NodeView extends Widget {
face.sync();
}
/** 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;
@@ -605,7 +563,6 @@ export class NodeView extends Widget {
}
override redraw(): void {
this.drawPicture();
const drawn = this.drawSignature();
if (drawn === this.drawnOf) return;
this.drawnOf = drawn;
+3
View File
@@ -18,6 +18,7 @@ import {
COMPRESSOR_ROWS,
type CompressorKey,
type CompressorNumbers,
ratioText,
} from './compressorView';
import {
type Box,
@@ -344,6 +345,8 @@ export class CompressorPanel extends Widget {
size: PANEL_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: key === 'ratio' ? ratioText : undefined,
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
+35 -593
View File
@@ -1,648 +1,90 @@
/**
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
* undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376). While
* the tab is seen, the spectrum of what passes the node is drawn behind the curve (#374).
* The tab of a Parametric EQ node (#370): the panel of `EqPanel.ts` under a header, pointed at the
* node whose tab is showing. While the tab is seen, the spectrum of what passes the node is drawn
* behind the curve (#374).
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import {
dbToY,
type EqArea,
type EqBand,
EQ_BAND_TYPES,
EQ_BANDS_PARAM,
EQ_FREQUENCY,
EQ_GAIN,
EQ_Q,
EQ_RANGE_PARAM,
EQ_RANGES,
EQ_SLOPES,
eqBandsOf,
eqBandsValue,
eqCurvePoints,
eqRangeOf,
fillShapes,
frequencyToX,
hasGain,
hasQ,
hasSlope,
MAX_EQ_BANDS,
newEqBand,
withBandSet,
xToFrequency,
yToDb,
} from '../../core/soundfx/parametricEq';
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { Spectrum } from '../../viewport/spectrum';
import {
crispText,
Dropdown,
HStack,
Label,
Menu,
NumberField,
pressCount,
Spacer,
Theme,
textStyle,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import { Label, Spacer, VStack } from '../../ui';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
import { ViewTween } from '../viewTween';
import {
bandAt,
EQ_GRID_FREQUENCIES,
eqGridDb,
handleOf,
SPECTRUM_FLOOR_DB,
spectrumPeak,
spectrumYs,
wheeledQ,
wheeledRange,
} from './eqView';
import { EqPanel } from './EqPanel';
/** The least the curve shrinks to; otherwise it takes the tab. */
const MIN_CURVE_WIDTH = 320;
const MIN_CURVE_HEIGHT = 160;
const HANDLE_RADIUS = 7;
const GRAB_RADIUS = HANDLE_RADIUS + 4;
/** Pixels of travel before a press on a band becomes a drag. */
const DRAG_THRESHOLD = 3;
/** Points the curve is drawn through, evenly in octaves. */
const CURVE_STEPS = 320;
const BAND_COLORS = [
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
];
/** Pixels between the points the spectrum is drawn through (#374). */
const SPECTRUM_STEP = 2;
/** What the curve asks of the tab around it. */
interface EqHost {
bands(): EqBand[];
range(): number;
zoom(range: number): void;
picked: number;
pick(index: number): void;
preview(bands: EqBand[]): void;
commit(bands: EqBand[]): void;
cancel(): void;
}
class EqCurve extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
/** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */
private readonly spectrum = new Graphics();
private readonly lines = new Graphics();
private readonly handles = new Graphics();
private readonly labels: Text[] = [];
/** As many as the most lines a range has; those of a range with fewer are hidden. */
private readonly dbLabels: Text[] = [];
private readonly numbers: Text[] = [];
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
private heard: Spectrum | null = null;
/** The dB drawn while a zoom is on its way (#377); null draws the node's. */
private shownRange: number | null = null;
private readonly tween = new ViewTween({
read: () => ({ scale: 1 / this.range, x: 0, y: 0 }),
write: (view) => (this.shownRange = 1 / view.scale),
draw: () => {
if (!this.tween.moving) this.shownRange = null;
this.redraw();
},
});
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
constructor(private readonly host: EqHost) {
super();
this.minWidth = MIN_CURVE_WIDTH;
this.minHeight = MIN_CURVE_HEIGHT;
this.cursor = 'crosshair';
this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles);
const muted = textStyle('small').clone();
muted.fill = Theme.colors.textMuted;
const onHandle = textStyle('small').clone();
onHandle.fill = Theme.colors.symbolOutline;
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length));
for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted }));
for (let i = 0; i < MAX_EQ_BANDS; i++)
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle }));
for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) {
text.eventMode = 'none';
this.addChild(text);
}
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
this.onPointerRelease(() => this.release());
this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e));
}
get area(): EqArea {
return { w: this.w, h: this.h, range: this.range };
}
private get range(): number {
return this.shownRange ?? this.host.range();
}
/** The dB drawn and whether a zoom is on its way – the check scripts sample it. */
get zoomShown(): { range: number; moving: boolean } {
return { range: this.range, moving: this.tween.moving };
}
/** Another node: a zoom on its way to the one before ends. */
settle(): void {
this.tween.land();
this.shownRange = null;
}
/** Where a band's handle is on screen – the check scripts aim with it. */
handleGlobal(index: number): { x: number; y: number } | null {
const band = this.host.bands()[index];
if (!band) return null;
const at = handleOf(band, this.area);
const origin = this.getGlobalPosition();
return { x: origin.x + at.x, y: origin.y + at.y };
}
/** Where a frequency and a gain are on screen. */
pointGlobal(frequency: number, db: number): { x: number; y: number } {
const origin = this.getGlobalPosition();
return { x: origin.x + frequencyToX(frequency, this.area), y: origin.y + dbToY(db, this.area) };
}
private local(e: FederatedPointerEvent): { x: number; y: number } {
return this.toLocal(e.global);
}
private press(e: FederatedPointerEvent): void {
const at = this.local(e);
const bands = this.host.bands();
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
if (e.button === 2) {
if (index >= 0) {
this.host.pick(-1);
this.host.commit(bands.filter((_, i) => i !== index));
}
return;
}
if (e.button !== 0) return;
if (index >= 0) {
this.host.pick(index);
this.drag = { index, startX: e.global.x, startY: e.global.y, moved: false };
return;
}
// A double-click on the free curve puts a band there; a single one lets go of the picked one.
if (pressCount(e, 'eq-curve') === 2 && bands.length < MAX_EQ_BANDS) {
const band = newEqBand(
Math.round(xToFrequency(at.x, this.area)),
Math.round(yToDb(at.y, this.area) * 10) / 10,
);
this.host.commit([...bands, band]);
this.host.pick(bands.length);
return;
}
this.host.pick(-1);
}
private move(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
return;
drag.moved = true;
const at = this.local(e);
const bands = this.host.bands();
const band = bands[drag.index];
if (!band) return;
const frequency = Math.round(xToFrequency(at.x, this.area));
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
this.host.preview(
withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }),
);
}
private release(): void {
const drag = this.drag;
this.drag = null;
if (!drag?.moved) return;
this.host.commit(this.host.bands());
}
/** The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). */
private wheel(e: FederatedWheelEvent): void {
if (e.deltaY === 0) return;
e.stopPropagation();
const at = this.toLocal(e.global);
const bands = this.host.bands();
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
const band = bands[index];
if (!band) {
const range = wheeledRange(this.host.range(), e.deltaY);
this.tween.change(() => {
this.shownRange = null;
this.host.zoom(range);
});
return;
}
if (!hasQ(band.type)) return;
this.host.pick(index);
this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) }));
}
override redraw(): void {
const c = Theme.colors;
const area = this.area;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
this.grid.clear();
let label = 0;
for (const { hz } of EQ_GRID_FREQUENCIES) {
const x = Math.round(frequencyToX(hz, area)) + 0.5;
this.grid.moveTo(x, 0).lineTo(x, this.h);
const text = this.labels[label++]!;
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
}
// The lines of the range a zoom is heading to, so they slide to their place with whole numbers.
const dbs = eqGridDb(this.host.range());
this.dbLabels.forEach((text, i) => {
const db = dbs[i];
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
text.visible = y > 0 && y < this.h;
if (!text.visible || db === undefined) return;
this.grid.moveTo(0, y).lineTo(this.w, y);
text.text = db > 0 ? `+${db}` : `${db}`;
text.position.set(3, y - text.height - 1);
});
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
const zero = Math.round(dbToY(0, area)) + 0.5;
this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 });
const bands = this.host.bands();
this.lines.clear();
const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2);
const xs = curves.xs;
curves.bands.forEach((ys, i) => {
trace(this.lines, xs, ys);
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
});
for (const shape of fillShapes(xs, curves.sum, zero))
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
trace(this.lines, xs, curves.sum);
this.lines.stroke({ color: c.accent, width: 2 });
this.handles.clear();
this.numbers.forEach((text, i) => {
const band = bands[i];
text.visible = Boolean(band);
if (!band) return;
const at = handleOf(band, area);
this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]);
if (i === this.host.picked)
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
});
this.drawSpectrum();
}
/** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */
showSpectrum(spectrum: Spectrum | null): void {
const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz);
const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz);
const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB;
const shown = loud ? spectrum : null;
if (!shown && !this.heard) return;
this.heard = shown;
if (shown)
this.seen = {
frames: this.seen.frames + 1,
peakIn: peakIn?.hz ?? 0,
peakOut: peakOut?.hz ?? 0,
levelOut: peakOut?.db ?? -Infinity,
binHz: shown.binHz,
};
this.drawSpectrum();
}
get hasSpectrum(): boolean {
return this.heard !== null;
}
private drawSpectrum(): void {
this.spectrum.clear();
const heard = this.heard;
if (!heard) return;
const c = Theme.colors;
const count = Math.floor(this.w / SPECTRUM_STEP);
const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w);
for (const [dbs, color, alpha, line] of [
[heard.in, c.textMuted, 0.12, false],
[heard.out, c.portAudio, 0.18, true],
] as const) {
const ys = spectrumYs(dbs, heard.binHz, xs, this.area);
this.spectrum
.poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h])
.fill({ color, alpha });
if (!line) continue;
trace(this.spectrum, xs, ys);
this.spectrum.stroke({ color, width: 1, alpha: 0.6 });
}
}
}
/** A path through the points. */
function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void {
g.moveTo(xs[0]!, ys[0]!);
for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!);
}
/**
* The tab of a Parametric EQ node (#370): one editor, pointed at the node whose tab is showing. It
* follows its node; once the node is gone the editor tells the tab row through `onGone`.
*/
export class EqEditor extends VStack implements EqHost {
readonly curve: EqCurve;
private assetId = '';
private nodeId = '';
picked = -1;
/** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */
private before: PropertyValue | undefined | null = null;
/** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */
export class EqEditor extends VStack {
readonly panel: EqPanel;
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
private readonly count = new Label('', { style: 'muted' });
private readonly header = new TabHeader();
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
private readonly bandLabel = new Label('', { style: 'small' });
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
private readonly perFrame: FramePreview;
/** The texts the tab was last laid out for; a drag changes only numbers and needs no layout. */
private laidOut = '';
/** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */
private listening = false;
constructor(private readonly state: EditorState) {
super();
this.perFrame = new FramePreview(state, 'eq-band');
this.curve = new EqCurve(this);
this.panel = new EqPanel(state);
this.header.add(this.title);
this.header.add(this.count);
this.header.add(new Spacer(), 1);
const numbers = (change: (value: number) => Partial<EqBand>) => ({
onInput: (value: number) => this.previewPicked(change(value)),
onChange: (value: number) => this.commitPicked(change(value)),
});
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
this.frequencyField = new NumberField(1000, {
width: 64,
min: EQ_FREQUENCY.min,
max: EQ_FREQUENCY.max,
step: 1,
decimals: 0,
...numbers((frequency) => ({ frequency })),
});
this.gainField = new NumberField(0, {
width: 52,
min: EQ_GAIN.min,
max: EQ_GAIN.max,
step: 0.1,
decimals: 1,
...numbers((gain) => ({ gain })),
});
this.qField = new NumberField(1, {
width: 52,
min: EQ_Q.min,
max: EQ_Q.max,
step: 0.01,
decimals: 3,
...numbers((q) => ({ q })),
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(this.bandLabel);
for (const [name, widget] of [
['Type', this.typeButton],
['Hz', this.frequencyField],
['dB', this.gainField],
['Q', this.qField],
['Slope', this.slopeButton],
] as const) {
row.add(new Label(name, { style: 'small' }));
row.add(widget);
}
this.body.add(this.curve, 1);
this.body.add(row);
this.body.add(
new Label(
'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
{ style: 'muted' },
),
);
this.add(this.header);
this.add(this.body, 1);
this.add(this.panel, 1);
state.on('project', () => this.sync());
}
/** Points the tab at a node; the band picked stays when it is the same node again. */
show(assetId: string, nodeId: string): void {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle();
this.perFrame.cancel();
this.hideSpectrum();
this.before = null;
this.assetId = assetId;
this.nodeId = nodeId;
this.picked = this.bands().length > 0 ? 0 : -1;
}
const shown = this.panel.shownFor;
if (shown && (shown.assetId !== assetId || shown.nodeId !== nodeId)) this.hideSpectrum();
this.panel.show(assetId, nodeId);
this.sync();
}
/** The tab is closed: the editor lets go of its node. */
close(): void {
this.perFrame.cancel();
this.hideSpectrum();
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
this.nodeId = '';
this.picked = -1;
this.laidOut = '';
this.panel.close();
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
return this.panel.shownFor;
}
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
tick(): void {
if (!this.nodeId) return;
const id = tapId(this.assetId, this.nodeId);
const shown = this.panel.shownFor;
if (!shown) return;
const id = tapId(shown.assetId, shown.nodeId);
soundLibrary.listen(id);
this.curve.showSpectrum(soundLibrary.spectrum(id));
this.listening = true;
this.panel.curve.showSpectrum(soundLibrary.spectrum(id));
}
/** The tab is not seen: no analyser is fed for it. */
/** The tab is not seen: it lets go of the tap once, so the node in the graph keeps it. */
hideSpectrum(): void {
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.curve.showSpectrum(null);
const shown = this.panel.shownFor;
if (this.listening && shown) soundLibrary.unlisten(tapId(shown.assetId, shown.nodeId));
this.listening = false;
this.panel.curve.showSpectrum(null);
}
/** Whether the spectrum is moving, so the tab has to be drawn every frame. */
get hasSpectrum(): boolean {
return this.curve.hasSpectrum;
}
private node(): GraphNode | null {
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
}
bands(): EqBand[] {
const node = this.node();
return node ? eqBandsOf(node.params) : [];
}
range(): number {
return eqRangeOf(this.node()?.params ?? {});
}
zoom(range: number): void {
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
}
pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.sync();
}
/**
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
* panels hear of it once a frame, however fast the mouse reports.
*/
preview(bands: EqBand[]): void {
const node = this.node();
if (!node) return;
if (this.before === null) this.before = node.params[EQ_BANDS_PARAM];
node.params[EQ_BANDS_PARAM] = eqBandsValue(bands);
this.perFrame.request();
}
commit(bands: EqBand[]): void {
this.perFrame.cancel();
const node = this.node();
if (!node) return;
this.restore(node);
const value = eqBandsValue(bands);
if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) {
this.state.preview();
return;
}
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value));
}
cancel(): void {
this.perFrame.cancel();
const node = this.node();
if (node) this.restore(node);
this.state.preview();
}
private restore(node: GraphNode): void {
if (this.before === null) return;
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
else node.params[EQ_BANDS_PARAM] = this.before;
this.before = null;
}
private previewPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
}
private commitPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
return this.panel.curve.hasSpectrum;
}
/** Delete in the tab takes the picked band – never the node the tab is about. */
removePicked(): void {
const index = this.picked;
if (index < 0) return;
this.picked = -1;
this.commit(this.bands().filter((_, i) => i !== index));
}
private openTypeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_BAND_TYPES.map((type) => ({
label: type.label,
current: type.value === band.type,
onSelect: () => this.commitPicked({ type: type.value }),
}));
new Menu(items).openBelow(this.typeButton);
}
private openSlopeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_SLOPES.map((slope) => ({
label: `${slope} dB/oct`,
current: slope === band.slope,
onSelect: () => this.commitPicked({ slope }),
}));
new Menu(items).openBelow(this.slopeButton);
this.panel.removePicked();
}
private sync(): void {
const node = this.node();
if (!node) return;
const bands = eqBandsOf(node.params);
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const title = `Parametric EQ – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
const shown = this.panel.shownFor;
if (!shown) return;
this.panel.sync();
const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
if (title === this.title.text) return;
this.title.setText(title);
this.count.setText(count);
this.bandLabel.setText(bandLabel);
this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—');
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
this.frequencyField.setNumber(band?.frequency ?? 1000);
this.gainField.setNumber(band?.gain ?? 0);
this.qField.setNumber(band?.q ?? 1);
this.typeButton.setEnabled(Boolean(band));
this.frequencyField.setEnabled(Boolean(band));
this.gainField.setEnabled(Boolean(band && hasGain(band.type)));
this.qField.setEnabled(Boolean(band && hasQ(band.type)));
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
const texts = [title, count, bandLabel].join('\n');
if (texts !== this.laidOut) {
this.laidOut = texts;
this.relayout();
}
this.curve.invalidate();
this.relayout();
}
}
/** The Parametric EQ node a tab is about, or null once it, or its asset, is gone. */
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxParametricEq.type ? node : null;
}
/**
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
*/
+42
View File
@@ -0,0 +1,42 @@
import { tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { EqPanel } from './EqPanel';
/**
* A Parametric EQ node drawn as its editor (#381): the panel of its tab, worked right in the graph,
* with the spectrum behind the curve while the node is in the picture.
*/
export class EqFace implements NodeFace {
readonly widget: EqPanel;
private listening = false;
constructor(
state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new EqPanel(state, true);
}
sync(): void {
this.widget.show(this.assetId, this.nodeId);
}
tick(): boolean {
if (!this.widget.shownFor) return false;
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.listening = true;
this.widget.curve.showSpectrum(soundLibrary.spectrum(id));
return this.widget.curve.hasSpectrum;
}
hide(): void {
if (!this.listening) return;
this.listening = false;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.widget.curve.showSpectrum(null);
}
}
+632
View File
@@ -0,0 +1,632 @@
/**
* The editor of a Parametric EQ node (#367): the curve and the numbers of the picked band. Its tab
* (#370) shows it, and since #381 so does the node itself in the graph. The curve is the sum of the
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
* undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the
* tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what passes the node is
* drawn behind the curve (#374).
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import {
dbToY,
type EqArea,
type EqBand,
EQ_BAND_TYPES,
EQ_BANDS_PARAM,
EQ_FREQUENCY,
EQ_GAIN,
EQ_Q,
EQ_RANGE_PARAM,
EQ_RANGES,
EQ_SLOPES,
eqBandsOf,
eqBandsValue,
eqCurvePoints,
eqRangeOf,
fillShapes,
frequencyToX,
hasGain,
hasQ,
hasSlope,
MAX_EQ_BANDS,
newEqBand,
withBandSet,
xToFrequency,
yToDb,
} from '../../core/soundfx/parametricEq';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { Spectrum } from '../../viewport/spectrum';
import {
crispText,
Dropdown,
HStack,
Label,
Menu,
NumberField,
pressCount,
Spacer,
Theme,
textStyle,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import { ViewTween } from '../viewTween';
import {
bandAt,
EQ_GRID_FREQUENCIES,
eqGridDb,
handleOf,
SPECTRUM_FLOOR_DB,
spectrumPeak,
spectrumYs,
wheeledQ,
wheeledRange,
} from './eqView';
/** The least the curve shrinks to; otherwise it takes the tab. */
const MIN_CURVE_WIDTH = 320;
const MIN_CURVE_HEIGHT = 160;
const HANDLE_RADIUS = 7;
const GRAB_RADIUS = HANDLE_RADIUS + 4;
/** Pixels of travel before a press on a band becomes a drag. */
const DRAG_THRESHOLD = 3;
/** Points the curve is drawn through, evenly in octaves. */
const CURVE_STEPS = 320;
const BAND_COLORS = [
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
];
/** Pixels between the points the spectrum is drawn through (#374). */
const SPECTRUM_STEP = 2;
/** The panel in the node (#381): a little narrower than the Compressor's, and taller. */
export const EQ_NODE_SIZE = { w: 720, h: 360 };
/** What the curve asks of the panel around it. */
interface EqHost {
bands(): EqBand[];
range(): number;
zoom(range: number): void;
picked: number;
pick(index: number): void;
preview(bands: EqBand[]): void;
commit(bands: EqBand[]): void;
}
class EqCurve extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
/** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */
private readonly spectrum = new Graphics();
private readonly lines = new Graphics();
private readonly handles = new Graphics();
private readonly labels: Text[] = [];
/** As many as the most lines a range has; those of a range with fewer are hidden. */
private readonly dbLabels: Text[] = [];
private readonly numbers: Text[] = [];
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
private heard: Spectrum | null = null;
/** The dB drawn while a zoom is on its way (#377); null draws the node's. */
private shownRange: number | null = null;
private readonly tween = new ViewTween({
read: () => ({ scale: 1 / this.range, x: 0, y: 0 }),
write: (view) => (this.shownRange = 1 / view.scale),
draw: () => {
if (!this.tween.moving) this.shownRange = null;
this.redraw();
},
});
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
constructor(private readonly host: EqHost) {
super();
this.minWidth = MIN_CURVE_WIDTH;
this.minHeight = MIN_CURVE_HEIGHT;
this.cursor = 'crosshair';
this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles);
const muted = textStyle('small').clone();
muted.fill = Theme.colors.textMuted;
const onHandle = textStyle('small').clone();
onHandle.fill = Theme.colors.symbolOutline;
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length));
for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted }));
for (let i = 0; i < MAX_EQ_BANDS; i++)
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle }));
for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) {
text.eventMode = 'none';
this.addChild(text);
}
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
this.onPointerRelease(() => this.release());
this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e));
}
get area(): EqArea {
return { w: this.w, h: this.h, range: this.range };
}
private get range(): number {
return this.shownRange ?? this.host.range();
}
/** The dB drawn and whether a zoom is on its way – the check scripts sample it. */
get zoomShown(): { range: number; moving: boolean } {
return { range: this.range, moving: this.tween.moving };
}
/** Another node: a zoom on its way to the one before ends. */
settle(): void {
this.tween.land();
this.shownRange = null;
}
/** Where a band's handle is on screen – the check scripts aim with it. */
handleGlobal(index: number): { x: number; y: number } | null {
const band = this.host.bands()[index];
if (!band) return null;
const at = this.toGlobal(handleOf(band, this.area));
return { x: at.x, y: at.y };
}
/** Where a frequency and a gain are on screen, at the graph's zoom in the node. */
pointGlobal(frequency: number, db: number): { x: number; y: number } {
const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) });
return { x: at.x, y: at.y };
}
private local(e: FederatedPointerEvent): { x: number; y: number } {
return this.toLocal(e.global);
}
private press(e: FederatedPointerEvent): void {
const at = this.local(e);
const bands = this.host.bands();
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
if (e.button === 2) {
if (index >= 0) {
e.stopPropagation();
this.host.pick(-1);
this.host.commit(bands.filter((_, i) => i !== index));
}
return;
}
if (e.button !== 0) return;
// In the node a press on the curve is the curve's: it neither picks nor moves the node.
e.stopPropagation();
if (index >= 0) {
this.host.pick(index);
this.drag = { index, startX: e.global.x, startY: e.global.y, moved: false };
return;
}
// A double-click on the free curve puts a band there; a single one lets go of the picked one.
if (pressCount(e, this) === 2 && bands.length < MAX_EQ_BANDS) {
const band = newEqBand(
Math.round(xToFrequency(at.x, this.area)),
Math.round(yToDb(at.y, this.area) * 10) / 10,
);
this.host.commit([...bands, band]);
this.host.pick(bands.length);
return;
}
this.host.pick(-1);
}
private move(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
return;
drag.moved = true;
const at = this.local(e);
const bands = this.host.bands();
const band = bands[drag.index];
if (!band) return;
const frequency = Math.round(xToFrequency(at.x, this.area));
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
this.host.preview(
withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }),
);
}
private release(): void {
const drag = this.drag;
this.drag = null;
if (!drag?.moved) return;
this.host.commit(this.host.bands());
}
/**
* The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in
* the tab: in the node the graph takes every wheel before the curve sees it (#383).
*/
private wheel(e: FederatedWheelEvent): void {
if (e.deltaY === 0) return;
const at = this.toLocal(e.global);
const bands = this.host.bands();
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
const band = bands[index];
e.stopPropagation();
if (!band) {
const range = wheeledRange(this.host.range(), e.deltaY);
this.tween.change(() => {
this.shownRange = null;
this.host.zoom(range);
});
return;
}
if (!hasQ(band.type)) return;
this.host.pick(index);
this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) }));
}
override redraw(): void {
const c = Theme.colors;
const area = this.area;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
this.grid.clear();
let label = 0;
for (const { hz } of EQ_GRID_FREQUENCIES) {
const x = Math.round(frequencyToX(hz, area)) + 0.5;
this.grid.moveTo(x, 0).lineTo(x, this.h);
const text = this.labels[label++]!;
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
}
// The lines of the range a zoom is heading to, so they slide to their place with whole numbers.
const dbs = eqGridDb(this.host.range());
this.dbLabels.forEach((text, i) => {
const db = dbs[i];
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
text.visible = y > 0 && y < this.h;
if (!text.visible || db === undefined) return;
this.grid.moveTo(0, y).lineTo(this.w, y);
text.text = db > 0 ? `+${db}` : `${db}`;
text.position.set(3, y - text.height - 1);
});
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
const zero = Math.round(dbToY(0, area)) + 0.5;
this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 });
const bands = this.host.bands();
this.lines.clear();
const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2);
const xs = curves.xs;
curves.bands.forEach((ys, i) => {
trace(this.lines, xs, ys);
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
});
for (const shape of fillShapes(xs, curves.sum, zero))
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
trace(this.lines, xs, curves.sum);
this.lines.stroke({ color: c.accent, width: 2 });
this.handles.clear();
this.numbers.forEach((text, i) => {
const band = bands[i];
text.visible = Boolean(band);
if (!band) return;
const at = handleOf(band, area);
this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]);
if (i === this.host.picked)
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
});
this.drawSpectrum();
}
/** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */
showSpectrum(spectrum: Spectrum | null): void {
const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz);
const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz);
const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB;
const shown = loud ? spectrum : null;
if (!shown && !this.heard) return;
this.heard = shown;
if (shown)
this.seen = {
frames: this.seen.frames + 1,
peakIn: peakIn?.hz ?? 0,
peakOut: peakOut?.hz ?? 0,
levelOut: peakOut?.db ?? -Infinity,
binHz: shown.binHz,
};
this.drawSpectrum();
}
get hasSpectrum(): boolean {
return this.heard !== null;
}
private drawSpectrum(): void {
this.spectrum.clear();
const heard = this.heard;
if (!heard) return;
const c = Theme.colors;
const count = Math.floor(this.w / SPECTRUM_STEP);
const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w);
for (const [dbs, color, alpha, line] of [
[heard.in, c.textMuted, 0.12, false],
[heard.out, c.portAudio, 0.18, true],
] as const) {
const ys = spectrumYs(dbs, heard.binHz, xs, this.area);
this.spectrum
.poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h])
.fill({ color, alpha });
if (!line) continue;
trace(this.spectrum, xs, ys);
this.spectrum.stroke({ color, width: 1, alpha: 0.6 });
}
}
}
/** A path through the points. */
function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void {
g.moveTo(xs[0]!, ys[0]!);
for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!);
}
/**
* The curve and the picked band's numbers, pointed at one node. The tab points its one panel at the
* node it shows; a node in the graph has a panel of its own at `EQ_NODE_SIZE`.
*/
export class EqPanel extends VStack implements EqHost {
readonly curve: EqCurve;
private assetId = '';
private nodeId = '';
picked = -1;
/** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */
private before: PropertyValue | undefined | null = null;
private readonly bandLabel = new Label('', { style: 'small' });
private readonly count = new Label('', { style: 'muted' });
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
private readonly row = new HStack({ gap: Theme.gap, align: 'center' });
private readonly perFrame: FramePreview;
/** The texts the panel was last laid out for; a drag changes only numbers and needs no layout. */
private laidOut = '';
constructor(
private readonly state: EditorState,
readonly inNode = false,
) {
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
this.perFrame = new FramePreview(state, inNode ? 'eq-node' : 'eq-band');
this.curve = new EqCurve(this);
const numbers = (change: (value: number) => Partial<EqBand>) => ({
onInput: (value: number) => this.previewPicked(change(value)),
onChange: (value: number) => this.commitPicked(change(value)),
});
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
this.frequencyField = new NumberField(1000, {
width: 64,
min: EQ_FREQUENCY.min,
max: EQ_FREQUENCY.max,
step: 1,
decimals: 0,
...numbers((frequency) => ({ frequency })),
});
this.gainField = new NumberField(0, {
width: 52,
min: EQ_GAIN.min,
max: EQ_GAIN.max,
step: 0.1,
decimals: 1,
...numbers((gain) => ({ gain })),
});
this.qField = new NumberField(1, {
width: 52,
min: EQ_Q.min,
max: EQ_Q.max,
step: 0.01,
decimals: 3,
...numbers((q) => ({ q })),
});
// In the node a press on a menu button is not one on the node: it neither picks nor moves it.
if (inNode)
for (const button of [this.typeButton, this.slopeButton])
button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
const row = this.row;
row.add(this.bandLabel);
for (const [name, widget] of [
['Type', this.typeButton],
['Hz', this.frequencyField],
['dB', this.gainField],
['Q', this.qField],
['Slope', this.slopeButton],
] as const) {
row.add(new Label(name, { style: 'small' }));
row.add(widget);
}
row.add(new Spacer(), 1);
row.add(this.count);
this.add(this.curve, 1);
this.add(row);
this.add(
new Label(
inNode
? 'Double-click: add a band. Drag: frequency, gain. Right-click: remove.'
: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
{ style: 'muted' },
),
);
}
override measure(): { w: number; h: number } {
return this.inNode ? EQ_NODE_SIZE : super.measure();
}
/** Points the panel at a node; the band picked stays when it is the same node again. */
show(assetId: string, nodeId: string): void {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle();
this.perFrame.cancel();
this.curve.showSpectrum(null);
this.before = null;
this.assetId = assetId;
this.nodeId = nodeId;
this.picked = this.bands().length > 0 ? 0 : -1;
}
this.sync();
}
/** The panel lets go of its node. */
close(): void {
this.perFrame.cancel();
this.curve.showSpectrum(null);
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
this.nodeId = '';
this.picked = -1;
this.laidOut = '';
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
private node(): GraphNode | null {
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
}
bands(): EqBand[] {
const node = this.node();
return node ? eqBandsOf(node.params) : [];
}
range(): number {
return eqRangeOf(this.node()?.params ?? {});
}
zoom(range: number): void {
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
}
pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.sync();
}
/**
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
* panels hear of it once a frame, however fast the mouse reports.
*/
preview(bands: EqBand[]): void {
const node = this.node();
if (!node) return;
if (this.before === null) this.before = node.params[EQ_BANDS_PARAM];
node.params[EQ_BANDS_PARAM] = eqBandsValue(bands);
this.perFrame.request();
}
commit(bands: EqBand[]): void {
this.perFrame.cancel();
const node = this.node();
if (!node) return;
this.restore(node);
const value = eqBandsValue(bands);
if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) {
this.state.preview();
return;
}
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value));
}
private restore(node: GraphNode): void {
if (this.before === null) return;
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
else node.params[EQ_BANDS_PARAM] = this.before;
this.before = null;
}
private previewPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
}
private commitPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
}
/** Delete in the tab takes the picked band – never the node the tab is about. */
removePicked(): void {
const index = this.picked;
if (index < 0) return;
this.picked = -1;
this.commit(this.bands().filter((_, i) => i !== index));
}
private openTypeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_BAND_TYPES.map((type) => ({
label: type.label,
current: type.value === band.type,
onSelect: () => this.commitPicked({ type: type.value }),
}));
new Menu(items).openBelow(this.typeButton);
}
private openSlopeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_SLOPES.map((slope) => ({
label: `${slope} dB/oct`,
current: slope === band.slope,
onSelect: () => this.commitPicked({ slope }),
}));
new Menu(items).openBelow(this.slopeButton);
}
/** After every change to the model: the panel shows its node as it now is. */
sync(): void {
const node = this.node();
if (!node) return;
const bands = eqBandsOf(node.params);
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
this.count.setText(count);
this.bandLabel.setText(bandLabel);
this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—');
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
this.frequencyField.setNumber(band?.frequency ?? 1000);
this.gainField.setNumber(band?.gain ?? 0);
this.qField.setNumber(band?.q ?? 1);
this.typeButton.setEnabled(Boolean(band));
this.frequencyField.setEnabled(Boolean(band));
this.gainField.setEnabled(Boolean(band && hasGain(band.type)));
this.qField.setEnabled(Boolean(band && hasQ(band.type)));
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
const texts = [count, bandLabel].join('\n');
if (texts !== this.laidOut) {
this.laidOut = texts;
this.relayout();
// Its own size stays, so the row would not lay its labels out anew by itself.
this.row.relayout();
}
this.curve.invalidate();
}
}
/** The Parametric EQ node a panel is about, or null once it, or its asset, is gone. */
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxParametricEq.type ? node : null;
}
+5 -1
View File
@@ -6,7 +6,7 @@
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
import type { PropertyBag } from '../../core/model/Properties';
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
import { clamp, snapToStep } from '../../ui/math';
import { clamp, formatNumber, snapToStep } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */
@@ -41,6 +41,10 @@ export const COMPRESSOR_KNOBS: Record<CompressorKey, { label: string; taper: num
makeup: { label: 'Makeup dB', taper: 1, decimals: 1 },
};
/** A ratio as a device prints it: `4:1` (#382). */
export const ratioText = (ratio: number): string =>
`${formatNumber(ratio, COMPRESSOR_KNOBS.ratio.decimals)}:1`;
export interface CompressorNumbers {
threshold: number;
knee: number;
+6 -3
View File
@@ -1,10 +1,13 @@
import type { GraphNode } from '../../core/graph';
import { SoundFxCompressor } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { CompressorFace } from './CompressorFace';
import { EqFace } from './EqFace';
/** The nodes drawn as a face of their own instead of rows (#380), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
return node.type === SoundFxCompressor.type ? new CompressorFace(state, assetId, node.id) : null;
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
return null;
}
+2 -1
View File
@@ -7,7 +7,8 @@ import type { GraphNode } from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
import { eqNodeOf, eqTabLabel } from './EqEditor';
import { eqTabLabel } from './EqEditor';
import { eqNodeOf } from './EqPanel';
export type NodeTabKind = 'eq' | 'compressor';
+54 -17
View File
@@ -10,6 +10,7 @@ import {
wheeledValue,
} from '../knob';
import { VStack } from '../layout/Stack';
import { clamp, formatNumber, roundTo } from '../math';
import { Theme } from '../Theme';
import { Widget } from '../Widget';
import { Label } from './Label';
@@ -31,8 +32,15 @@ export interface KnobOptions extends KnobRange {
size?: number;
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
look?: 'flat' | 'chrome';
/** Whether a click on the number types one; a knob on a node (#380) only shows it. */
/**
* Whether a click on the number types one. A knob on a node (#380) only shows it, as a centred
* label without a box (#382).
*/
typed?: boolean;
/** How a shown number reads, `4:1` for a ratio (#382); typed numbers keep their plain digits. */
format?: (value: number) => string;
/** The shown number's colour, to stand on a panel's own plate. */
ink?: number;
}
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
@@ -178,13 +186,28 @@ class Dial extends Widget {
/** The value's arc on a chrome dial: the orange of a lit scale. */
const CHROME_ARC = 0xff8a1e;
/**
* The share of a dial's size left empty at its bottom: the scale ends 45° below the level, at
* 0.71 of the radius, the box at 1. A little of it stays as air above the number.
*/
const OPEN_BOTTOM = 0.12;
/** A number that only shows: as high as its text, not a whole row, so it hugs the dial (#382). */
function tightLabel(text: string, ink: number | undefined): Label {
const label = new Label(text, { align: 'center', color: ink });
label.minWidth = 56;
label.minHeight = 0;
return label;
}
/**
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
* below, which takes a typed value on a click.
*/
export class Knob extends VStack {
readonly dial: Dial;
readonly field: NumberField;
/** The number below: a field to type into, or with `typed: false` a label that only shows it. */
readonly field: NumberField | Label;
private readonly caption: Label;
private current: number;
@@ -193,20 +216,24 @@ export class Knob extends VStack {
value: number,
readonly options: KnobOptions,
) {
super({ gap: 2, align: 'center' });
// A shown number without a name sits up in the dial's open bottom, below the ends of its scale (#382).
const tucked = options.typed === false && !label;
super({ gap: tucked ? -Math.round((options.size ?? DIAL_SIZE) * OPEN_BOTTOM) : 2, align: 'center' });
this.current = value;
this.caption = new Label(label, { style: 'small' });
this.dial = new Dial(this);
this.field = new NumberField(value, {
width: 56,
min: options.min,
max: options.max,
step: options.step,
decimals: options.decimals,
onInput: (typed) => this.preview(typed),
onChange: (typed) => this.commit(typed),
});
if (options.typed === false) this.field.eventMode = 'none';
this.field =
options.typed === false
? tightLabel(this.text(value), options.ink)
: new NumberField(value, {
width: 56,
min: options.min,
max: options.max,
step: options.step,
decimals: options.decimals,
onInput: (typed) => this.preview(typed),
onChange: (typed) => this.commit(typed),
});
// Without a name the knob has none above it: a panel engraves its own.
if (label) this.add(this.caption);
this.add(this.dial);
@@ -217,12 +244,15 @@ export class Knob extends VStack {
return this.current;
}
/** The number as it reads below the dial. */
get shown(): string {
return this.field instanceof NumberField ? this.field.value : this.field.text;
}
/** Shows a value from outside; fires nothing. A drag going on keeps its own. */
setValue(value: number): void {
if (this.dial.dragging || value === this.current) return;
this.current = value;
this.field.setNumber(value);
this.dial.invalidate();
this.show(value);
}
preview(value: number): void {
@@ -237,10 +267,17 @@ export class Knob extends VStack {
private show(value: number): void {
this.current = value;
this.field.setNumber(value);
if (this.field instanceof NumberField) this.field.setNumber(value);
else this.field.setText(this.text(value));
this.dial.invalidate();
}
/** A shown number: rounded to its decimals and clamped like a typed one, then formatted. */
private text(value: number): string {
const shown = clamp(roundTo(value, this.options.decimals), this.options.min, this.options.max);
return this.options.format?.(shown) ?? formatNumber(shown, this.options.decimals);
}
/** Where the dial is on screen, for the check scripts. */
dialGlobal(): { x: number; y: number } {
const at = this.dial.getGlobalPosition();
+11 -4
View File
@@ -15,6 +15,8 @@ export interface LabelOptions {
* asks for no width of its own, and its height follows `heightFor`.
*/
wrap?: boolean;
/** The text's own colour instead of the style's – a label on a surface of its own (#382). */
color?: number;
}
/** Text that truncates with an ellipsis when narrower than its content, or wraps with `wrap`. */
@@ -25,6 +27,7 @@ export class Label extends Widget {
private readonly padding: number;
private readonly fixedWidth: number;
private readonly wrap: boolean;
private readonly color: number | undefined;
private underline: Graphics | null = null;
constructor(text = '', options: LabelOptions = {}) {
@@ -35,18 +38,22 @@ export class Label extends Widget {
this.padding = options.padding ?? 0;
this.fixedWidth = options.width ?? 0;
this.wrap = options.wrap ?? false;
this.color = options.color;
this.minWidth = this.fixedWidth;
this.textNode = crispText({ text, style: this.styleFor(options.style ?? 'body') });
this.addChild(this.textNode);
this.minHeight = Theme.rowHeight;
}
/** The shared style, or an own copy for a wrapping label: it carries its wrap width. */
/** The shared style, or an own copy for a wrapping label (its wrap width) or one of its own colour. */
private styleFor(token: TextStyleToken): TextStyle {
if (!this.wrap) return textStyle(token);
if (!this.wrap && this.color === undefined) return textStyle(token);
const style = textStyle(token).clone();
style.wordWrap = true;
style.breakWords = true;
if (this.color !== undefined) style.fill = this.color;
if (this.wrap) {
style.wordWrap = true;
style.breakWords = true;
}
return style;
}
+3 -2
View File
@@ -113,9 +113,10 @@ export class Popup extends Widget {
/** Opens left-aligned under `anchor`, and never narrower than it. */
openBelow(anchor: Widget, gap = 2): void {
const g = anchor.getGlobalPosition();
// Below the anchor as drawn: one in a zoomed graph is not `h` pixels tall on screen.
const g = anchor.toGlobal({ x: 0, y: anchor.h });
this.minWidth = Math.max(this.minWidth, anchor.w);
this.open(g.x, g.y + anchor.h + gap);
this.open(g.x, g.y + gap);
}
openCentered(): void {
+28 -31
View File
@@ -1,18 +1,20 @@
/**
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, the curve the
* editor draws from them, the curve in the node (#375), and the taps the spectrum is heard at (#374).
* editor draws from them, and the taps the spectrum is heard at (#374).
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { compileSoundFx } from '../../src/core/soundfx/compile';
import { fxChainOf, SoundFxParametricEq, tapId } from '../../src/core/soundfx/nodes';
import { fxChainOf, tapId } from '../../src/core/soundfx/nodes';
import {
dbToY,
type EqBand,
eqBandResponse,
eqBandsOf,
eqCurvePoints,
eqCurves,
eqRangeOf,
fillShapes,
frequencyToX,
MAX_EQ_BANDS,
@@ -151,45 +153,40 @@ describe('the bands of a Parametric EQ', () => {
});
});
describe('the curve in the node (#375)', () => {
const picture = SoundFxParametricEq.picture!;
describe('the curve as the panel draws it', () => {
const W = 164;
const area = { w: W, h: picture.height, range: 24 };
const lineOf = (bands: unknown, range?: number) =>
picture.shapes({ bands, range } as never, W).find((s) => s.kind === 'line')!;
const topOf = (line: { points: number[] }) => Math.min(...line.points.filter((_, i) => i % 2 === 1));
const H = 60;
const area = { w: W, h: H, range: 24 };
const topOf = (ys: number[]) => Math.min(...ys);
it('has its highest point where the peak is, as high as its gain', () => {
const line = lineOf([band({ gain: 6 })]);
let top = 1;
for (let i = 3; i < line.points.length; i += 2) if (line.points[i]! < line.points[top]!) top = i;
expect(Math.abs(line.points[top - 1]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
expect(line.points[top]).toBeCloseTo(dbToY(6, area), 1);
expect(line.points.length).toBe(97 * 2);
const { xs, sum } = eqCurvePoints([band({ gain: 6 })], area, 96);
const top = sum.indexOf(topOf(sum));
expect(Math.abs(xs[top]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
expect(sum[top]).toBeCloseTo(dbToY(6, area), 1);
expect(xs.length).toBe(97);
});
it('keeps its box without a band, flat on the 0 dB line', () => {
const shapes = picture.shapes({}, W);
const zero = dbToY(0, area);
expect(picture.height).toBe(60);
expect(shapes[0]).toEqual({ kind: 'guide', points: [0, zero, W, zero] });
const line = shapes.find((s) => s.kind === 'line')!;
expect(line.points.filter((_, i) => i % 2 === 1).every((y) => y === zero)).toBe(true);
it('lies flat on the 0 dB line without a band', () => {
const { sum } = eqCurvePoints([], area, 96);
expect(sum.every((y) => y === dbToY(0, area))).toBe(true);
});
it('stays inside its box however deep a cut goes', () => {
const line = lineOf([band({ type: 'highpass', frequency: 2000, slope: 48 })]);
const ys = line.points.filter((_, i) => i % 2 === 1);
expect(Math.max(...ys)).toBe(picture.height);
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
it('stays inside its box however deep a cut goes, or as far past it as it is let', () => {
const cut = [band({ type: 'highpass', frequency: 2000, slope: 48 })];
const { sum } = eqCurvePoints(cut, area, 96);
expect(Math.max(...sum)).toBe(H);
expect(Math.min(...sum)).toBeGreaterThanOrEqual(0);
expect(Math.max(...eqCurvePoints(cut, area, 96, 2).sum)).toBe(H + 2);
});
it('shows the dB its tab is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => {
it('shows the dB the node is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => {
const peak = [band({ gain: 6 })];
expect(topOf(lineOf(peak))).toBeCloseTo(dbToY(6, area), 1);
expect(topOf(lineOf(peak, 12))).toBeCloseTo(dbToY(6, { ...area, range: 12 }), 1);
expect(topOf(lineOf(peak, 12))).toBeCloseTo(picture.height / 4, 1);
expect(lineOf(peak, 7)).toEqual(lineOf(peak));
const at = (params: object) =>
topOf(eqCurvePoints(peak, { ...area, range: eqRangeOf(params as never) }, 96).sum);
expect(at({})).toBeCloseTo(dbToY(6, area), 1);
expect(at({ range: 12 })).toBeCloseTo(H / 4, 1);
expect(at({ range: 7 })).toBe(at({}));
});
});
+6
View File
@@ -21,6 +21,7 @@ import {
picturesLayout,
ratioFor,
ratioHandle,
ratioText,
reductionOf,
reductionShapes,
thresholdHandle,
@@ -95,6 +96,11 @@ describe('the numbers of a Compressor node (#380)', () => {
expect(autoGainText({ ...numbers, autoGain: false })).toBe('off');
expect(autoGainText({ ...numbers, autoGain: true })).toMatch(/^\+\d+\.\d dB$/);
});
it('prints the ratio as a device does, without a trailing zero (#382)', () => {
expect(ratioText(4)).toBe('4:1');
expect(ratioText(2.5)).toBe('2.5:1');
});
});
describe('the level picture of a Compressor', () => {
+82 -6
View File
@@ -647,7 +647,8 @@ export async function sound(t) {
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
await frame();
const seen = __gm.eq.spectrum().taps;
__gm.openDocument(${JSON.stringify(`asset:${fxId}`)});
// Not the sound FX graph: the node there listens to the same tap (#381).
__gm.showTab('scene');
await frame();
await frame();
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
@@ -663,6 +664,76 @@ export async function sound(t) {
JSON.stringify(unseen),
);
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
// #381: the EQ node shows the panel of its tab at full size, its inputs on the left and its output
// on the right. A double-click on its curve adds a band without opening the tab, a drag is one
// undo step, the wheel over a band zooms the graph and leaves its Q and the dB alone (#383),
// and a right-click takes the band. Played, the node
// draws the spectrum, its tab open in the row.
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const eqPanel = () => json("__gm.eq.panel('fx-eq')");
const eqFace = await eqPanel();
const eqRect = eqFace.rect;
const besideEq = (p) => p.y > eqRect.y && p.y < eqRect.y + eqRect.h;
check(
'a Parametric EQ node shows the panel of its tab at full size, its inputs on the left of it and its output on the right',
eqFace.shown &&
eqFace.size.w === 720 &&
eqFace.size.h === 360 &&
Object.values(eqFace.inputs).length >= 1 &&
Object.values(eqFace.inputs).every((p) => p.x < eqRect.x && besideEq(p)) &&
Object.values(eqFace.outputs).length === 1 &&
Object.values(eqFace.outputs).every((p) => p.x > eqRect.x + eqRect.w && besideEq(p)) &&
eqFace.curve.h > eqRect.h / 2 &&
eqFace.bands.length === 1,
JSON.stringify(eqFace),
);
const eqNodeAt = (hz, db) => json(`__gm.eq.panelPointAt('fx-eq', ${hz}, ${db})`);
const eqAddAt = await eqNodeAt(250, 6);
await t.doubleClick(eqAddAt.x, eqAddAt.y);
const eqNodeAdded = { panel: await eqPanel(), docs: await json('__gm.documents()') };
const eqNodeTo = await eqNodeAt(500, -3);
const eqNodeHandle = eqNodeAdded.panel.handles[1];
await drag(eqNodeHandle.x, eqNodeHandle.y, eqNodeTo.x, eqNodeTo.y);
const eqNodeDragged = (await eqPanel()).bands[1];
await press('z', { ctrl: true });
const eqNodeUndone = (await eqPanel()).bands[1];
const eqRangeBefore = await json('__gm.eq.range()');
await t.wheel(eqNodeHandle.x, eqNodeHandle.y, -100);
const eqNodeWheeled = { panel: await eqPanel(), range: await json('__gm.eq.range()') };
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const eqNodeLast = (await eqPanel()).handles[1];
await click(eqNodeLast.x, eqNodeLast.y, 'right');
const eqNodeRemoved = await eqPanel();
check(
'in the node, a double-click on the curve adds a band without opening the tab, a drag moves it as one undo step, the wheel over it zooms the graph and leaves its Q, and a right-click takes it',
eqNodeAdded.panel.bands.length === 2 &&
bandNear(eqNodeAdded.panel.bands[1], 250, 6) &&
eqNodeAdded.panel.picked === 1 &&
eqNodeAdded.docs.active === `asset:${fxId}` &&
bandNear(eqNodeDragged, 500, -3) &&
bandNear(eqNodeUndone, 250, 6) &&
eqNodeWheeled.panel.bands[1].q === eqNodeUndone.q &&
eqNodeWheeled.panel.rect.w > eqRect.w + 10 &&
eqNodeWheeled.range === eqRangeBefore &&
eqNodeRemoved.bands.length === 1 &&
bandNear(eqNodeRemoved.bands[0], 4000, -6),
JSON.stringify({ eqNodeAdded, eqNodeDragged, eqNodeUndone, eqNodeWheeled, eqNodeRemoved }),
);
await evaluate('__gm.soundfx.fit()');
await evaluate('__gm.sound.stopAll()');
const eqNodeFrames = (await eqPanel()).spectrum.frames;
await evaluate('__gm.soundfx.play()');
const eqNodeHeard = await waitFor(eqPanel, (p) => p.spectrum.frames >= eqNodeFrames + 3);
await evaluate('__gm.sound.stopAll()');
check(
'played, the EQ node draws the blip’s 880 Hz behind its curve, its tab open in the row',
eqNodeHeard.spectrum.frames >= eqNodeFrames + 3 &&
nearBlip(eqNodeHeard.spectrum.peakOut) &&
(await json('__gm.documents()')).docs.includes(`eq:${fxId}:fx-eq`),
JSON.stringify(eqNodeHeard.spectrum),
);
await shot('34-eq-node');
await evaluate('__gm.sound.stopAll()');
const eqBefore = await stats();
@@ -875,8 +946,10 @@ export async function sound(t) {
const hwOutputs = Object.values(hwShown.outputs);
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
check(
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right',
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right, its numbers as plain labels, the ratio as 8:1',
hwShown.shown &&
hwShown.numbers.ratio === '8:1' &&
hwShown.numbers.threshold === '-50' &&
hwShown.size.w === 820 &&
hwShown.size.h === 280 &&
hwInputs.length === 8 &&
@@ -899,17 +972,20 @@ export async function sound(t) {
const hwRatio = hwShown.dials.ratio;
await t.wheel(hwRatio.x, hwRatio.y, -100);
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
await t.doubleClick(hwRatio.x, hwRatio.y);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const hwRefit = (await hwPanel()).dials.ratio;
await t.doubleClick(hwRefit.x, hwRefit.y);
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
await sleep(100);
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
check(
'in the node, a knob turns with a drag up as one undo step and a notch of the wheel without zooming the graph, a double-click resets it without opening the tab, and the lever switches auto-gain',
'in the node, a knob turns with a drag up as one undo step, the wheel over it zooms the graph and leaves it, a double-click resets it without opening the tab, and the lever switches auto-gain',
hwDragged.threshold > -50 &&
hwUndone.threshold === -50 &&
hwWheeled.numbers.ratio > 8 &&
Math.abs(hwWheeled.panel.rect.w - hwRect.w) < 0.5 &&
hwWheeled.numbers.ratio === 8 &&
hwWheeled.panel.rect.w > hwRect.w + 10 &&
hwReset.numbers.ratio === 4 &&
hwReset.docs.active === `asset:${fxId}` &&
hwLever.numbers.autoGain === true &&