Edit a Filter in its node and resize EQ and Filter nodes (#385)

A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 14:30:00 +02:00
1 parent 17f47f6956
commit c087993000
26 files changed
+1514 -656

No files matched your search

+17 -2
View File
@@ -125,6 +125,7 @@ export type CentreTab =
| 'routing'
| 'eq'
| 'compressor'
| 'filter'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -135,7 +136,7 @@ export type TabId = CentreTab | PluginTabId;
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
* and so has a Compressor (#378).
* and so have a Compressor (#378) and a Filter (#385).
*/
export type DocumentId =
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
@@ -183,6 +184,7 @@ const PANE_ORDER = [
'routing',
'eq',
'compressor',
'filter',
'management',
] as const;
@@ -210,6 +212,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
routing: 'Master routing',
eq: 'Parametric EQ',
compressor: 'Compressor',
filter: 'Filter',
management: 'User management',
};
@@ -279,6 +282,7 @@ export class Editor implements PlayHost {
readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor;
readonly compressorEditor: CompressorEditor;
readonly filterEditor: EqEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
@@ -384,6 +388,7 @@ export class Editor implements PlayHost {
this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state);
this.compressorEditor = new CompressorEditor(state);
this.filterEditor = new EqEditor(state, 'filter');
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -508,6 +513,7 @@ export class Editor implements PlayHost {
routing: this.routingEditor,
eq: this.eqEditor,
compressor: this.compressorEditor,
filter: this.filterEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -614,7 +620,7 @@ export class Editor implements PlayHost {
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
// A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it.
// A tab per Parametric EQ (#370), Compressor (#378) or Filter (#385) node, opened by a double-click on it.
state.on('nodeTab', () => {
const target = state.openedNode;
const kind = target && nodeTabOf(target.type);
@@ -642,6 +648,7 @@ export class Editor implements PlayHost {
this.nodeTargets.clear();
this.eqEditor.close();
this.compressorEditor.close();
this.filterEditor.close();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.modeChanged());
@@ -712,6 +719,9 @@ export class Editor implements PlayHost {
// The selection is the EQ's node, still selected from the double-click: it is never what goes.
this.eqEditor.removePicked();
return true;
case 'filter':
// A Filter has its one band and nothing else to take; its node stays as well.
return true;
default:
return false;
}
@@ -908,6 +918,7 @@ export class Editor implements PlayHost {
if (this.nodeTargets.delete(doc)) {
if (kind === 'eq') this.eqEditor.close();
if (kind === 'compressor') this.compressorEditor.close();
if (kind === 'filter') this.filterEditor.close();
}
if (doc === 'routing') this.routingEditor.close();
}
@@ -1145,6 +1156,7 @@ export class Editor implements PlayHost {
const node = this.nodeTargets.get(doc);
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
if (kind === 'filter' && node) this.filterEditor.show(node.assetId, node.nodeId);
}
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
@@ -1335,9 +1347,11 @@ export class Editor implements PlayHost {
['instrument', this.instrumentEditor.canvas],
] as const;
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
if (!this.shows('filter')) this.filterEditor.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('filter')) this.filterEditor.tick();
if (this.shows('compressor')) this.compressorEditor.tick();
this.facesMoving = false;
for (const [kind, canvas] of faces)
@@ -1379,6 +1393,7 @@ export class Editor implements PlayHost {
(this.shows('song') && this.songEditor.isPlaying) ||
(this.shows('sound') && this.soundEditor.isPlaying) ||
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
(this.shows('filter') && this.filterEditor.hasSpectrum) ||
(this.shows('compressor') && this.compressorEditor.moving) ||
this.facesMoving
);
+35 -15
View File
@@ -5,7 +5,8 @@ import type { Widget } from '../../ui';
import { currentNodePalette } from '../graph/NodePalette';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { GainPanel } from '../soundfx/GainPanel';
import { EqPanel } from '../soundfx/EqPanel';
import { CurvePanel } from '../soundfx/CurvePanel';
import type { EqEditor } from '../soundfx/EqEditor';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -127,17 +128,7 @@ 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.panel.bands(),
picked: () => editor.eqEditor.panel.picked,
/** The dB the curve is zoomed to, above and below 0 (#376). */
range: () => editor.eqEditor.panel.range(),
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
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),
...curveTabHooks(editor.eqEditor, () => editor.tab === 'eq'),
/**
* 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
@@ -151,7 +142,7 @@ export function graphHooks({ state, editor }: HookContext) {
panel: (nodeId: string) => {
const canvas = editor.soundFxEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof EqPanel)) return null;
if (!(panel instanceof CurvePanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
@@ -161,6 +152,7 @@ export function graphHooks({ state, editor }: HookContext) {
size: { w: panel.w, h: panel.h },
curve: screenRect(panel.curve),
node: node?.rect ?? null,
corner: node?.corner ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
bands: panel.bands(),
@@ -169,14 +161,21 @@ export function graphHooks({ state, editor }: HookContext) {
handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)),
type: screenRect(panel.typeButton),
spectrum: panel.curve.seen,
rows: panel.shownRows,
labels: panel.curve.shownLabels,
};
},
/** 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;
return panel instanceof CurvePanel ? panel.curve.pointGlobal(hz, db) : null;
},
},
/** The Filter's tab (#385), as the EQ's; its node's panel is `eq.panel` too. */
filter: {
...curveTabHooks(editor.filterEditor, () => editor.tab === 'filter'),
spectrum: () => ({ ...editor.filterEditor.panel.curve.seen, taps: soundLibrary.spectrumTaps() }),
},
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
compressor: {
shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null),
@@ -381,7 +380,7 @@ function screenRect(widget: Widget): { x: number; y: number; w: number; h: numbe
}
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
function eqFields(panel: EqPanel) {
function eqFields(panel: CurvePanel) {
return {
type: panel.typeButton.text,
slope: panel.slopeButton.enabled ? panel.slopeButton.text : null,
@@ -390,3 +389,24 @@ function eqFields(panel: EqPanel) {
frequency: panel.frequencyField.value,
};
}
/** A tab with a curve (#370, #385): for which node it is showing, its bands and where to aim. */
function curveTabHooks(tab: EqEditor, seen: () => boolean) {
const panel = tab.panel;
return {
shown: () => (seen() ? tab.shownFor : null),
bands: () => panel.bands(),
picked: () => panel.picked,
/** The dB the curve is zoomed to, above and below 0 (#376). */
range: () => panel.range(),
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
zoomShown: () => panel.curve.zoomShown,
handleAt: (index: number) => panel.curve.handleGlobal(index),
pointAt: (hz: number, db: number) => panel.curve.pointGlobal(hz, db),
fields: () => eqFields(panel),
typeAt: () => centreOf(panel.typeButton),
/** What is drawn under the curve, and which axis labels (#385). */
rows: () => panel.shownRows,
labels: () => panel.curve.shownLabels,
};
}
+17 -4
View File
@@ -371,7 +371,11 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
if (!asset) return;
this.annotationLayer.sync(asset.graph);
for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y);
for (const node of asset.graph.nodes) {
const view = this.views.get(node.id);
view?.position.set(node.x, node.y);
view?.refit();
}
this.redraw();
}
@@ -539,14 +543,17 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.face ?? null;
}
/** A node's box on screen and the keys of its ports, for the check scripts. */
nodeScreen(nodeId: string): { rect: Rect; inputs: string[]; outputs: string[] } | null {
/** A node's box on screen, the keys of its ports and the corner it is resized by (#385), for the check scripts. */
nodeScreen(
nodeId: string,
): { rect: Rect; inputs: string[]; outputs: string[]; corner: { x: number; y: number } | null } | null {
const view = this.views.get(nodeId);
if (!view) return null;
return {
rect: globalRect(view, view.w, view.h),
inputs: view.def.inputs.map((p) => p.key),
outputs: view.def.outputs.map((p) => p.key),
corner: view.cornerGlobal(),
};
}
@@ -697,6 +704,12 @@ export class GraphCanvas extends Widget {
this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global);
return;
}
if (view.cornerAt(local.x, local.y)) {
this.state.select(nodeSelection(this.assetId, [view.node.id]));
this.nodeLayer.addChild(view); // raise
this.drags.grabCorner(view, e.global);
return;
}
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
if (view.chevronAt(local.x, local.y)) {
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
@@ -843,7 +856,7 @@ export class GraphCanvas extends Widget {
return;
}
this.panning = null;
if (this.drags.releaseBox()) {
if (this.drags.releaseCorner() || this.drags.releaseBox()) {
this.redraw();
return;
}
+62 -1
View File
@@ -1,4 +1,4 @@
import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js';
import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js';
import {
asNumber,
asString,
@@ -31,6 +31,7 @@ import {
crispText,
} from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { RESIZE_HANDLE } from './AnnotationView';
import { collapsedNodeWidth } from './folding';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
@@ -164,6 +165,10 @@ export class NodeView extends Widget {
private drawnOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */
private readonly corner = new Container();
/** What the node was last drawn from, so a drag of its corner can draw it again (#385). */
private last: { graph: Graph; project: Project | null; problem: string | null } | null = null;
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
@@ -188,6 +193,10 @@ export class NodeView extends Widget {
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.ports);
if (face) this.addChild(face.widget);
this.corner.eventMode = 'static';
this.corner.cursor = 'nwse-resize';
this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE);
this.addChild(this.corner);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -216,6 +225,7 @@ export class NodeView extends Widget {
): void {
this.def = def;
this.problem = problem;
this.last = { graph, project, problem };
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
const hidden = new Set([
...(this.def.hiddenParams?.(node.params) ?? []),
@@ -284,6 +294,8 @@ export class NodeView extends Widget {
if (face) this.placeFace(face);
else this.face.hide();
}
this.corner.visible = this.resizable;
this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE);
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
@@ -324,6 +336,7 @@ export class NodeView extends Widget {
this.linked,
this.targetPort,
this.headerHeight(),
this.resizable,
this.def.inputs.map(port),
this.def.outputs.map(port),
]);
@@ -428,10 +441,52 @@ export class NodeView extends Widget {
return (!this.collapsed && this.face) || null;
}
/** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */
private faceSize(): { w: number; h: number } {
const min = this.face?.minSize;
const size = this.node.size;
if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) };
return this.face?.widget.measure() ?? { w: 0, h: 0 };
}
/** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */
get resizable(): boolean {
return this.shownFace()?.minSize !== undefined;
}
/** Whether a local point is on that corner. */
cornerAt(x: number, y: number): boolean {
return (
this.resizable &&
x >= this.w - RESIZE_HANDLE &&
x <= this.w &&
y >= this.h - RESIZE_HANDLE &&
y <= this.h
);
}
cornerGlobal(): { x: number; y: number } | null {
if (!this.resizable) return null;
const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 });
return { x: at.x, y: at.y };
}
/** How much of the node is not its face: the least the node itself can be dragged to follows from it. */
get faceChrome(): { w: number; h: number } {
const size = this.faceSize();
return { w: this.w - size.w, h: this.h - size.h };
}
/** A drag of the corner changed the node's size in the model: the node is drawn at it. */
refit(): void {
const last = this.last;
const face = this.shownFace();
if (!last || !face) return;
const size = this.faceSize();
if (size.w === face.widget.w && size.h === face.widget.h) return;
this.sync(this.node, last.graph, last.project, this.def, last.problem);
}
/** Each side of a face as wide as the longest name on it. */
private faceSides(): { left: number; right: number } {
const side = (ports: PortDef[]) =>
@@ -591,6 +646,12 @@ export class NodeView extends Widget {
this.box
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
if (this.resizable) {
// The floating window's corner (#371), so it reads as the same handle.
for (const at of [4, 8, 12])
this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at);
this.box.stroke({ width: 1, color: c.textMuted });
}
this.ports.clear();
const r = PORT_RADIUS;
+68 -2
View File
@@ -1,8 +1,9 @@
import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { ResizeNodeCommand } from '../../core/commands/graphCommands';
import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph';
import { cancelFrameWork, onFrame } from '../../ui';
import { dragOffset, resizeSize } from '../framing';
import { type Box, dragOffset, resizeSize } from '../framing';
import {
boxOf,
type BoxSeed,
@@ -15,6 +16,7 @@ import {
import type { AnnotationView } from './AnnotationView';
import type { GraphCanvasHost } from './graphHost';
import { GRID_STEP } from './graphView';
import type { NodeView } from './NodeView';
import type { Point } from './transitions';
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
@@ -38,6 +40,19 @@ export class GraphDrags {
pointer: Point;
moved: boolean;
} | null = null;
/**
* A node dragged by its corner (#385): the node as it was grabbed, how much of it is not its face,
* and the least it can be. The model holds the face's size, the drag works on the whole node.
*/
private corner: {
nodeId: string;
start: Omit<Box, 'id'>;
chrome: { w: number; h: number };
min: { w: number; h: number };
before: { w: number; h: number } | undefined;
pointer: Point;
moved: boolean;
} | null = null;
/**
* The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and
@@ -90,9 +105,27 @@ export class GraphDrags {
};
}
/** A press on the corner of a node whose face may be resized (#385). */
grabCorner(view: NodeView, global: Point): void {
const min = view.face?.minSize;
if (!min) return;
const chrome = view.faceChrome;
const node = view.node;
this.corner = {
nodeId: node.id,
start: { x: node.x, y: node.y, w: view.w, h: view.h },
chrome,
min: { w: min.w + chrome.w, h: min.h + chrome.h },
before: node.size && { ...node.size },
pointer: this.host.toGraph(global),
moved: false,
};
}
/** Moves what is dragged in the model, for the preview; false when nothing is. */
move(global: Point): boolean {
if (this.box) this.previewBox(this.host.toGraph(global));
if (this.corner) this.previewCorner(this.host.toGraph(global));
else if (this.box) this.previewBox(this.host.toGraph(global));
else if (this.nodes) this.previewNodes(this.host.toGraph(global));
else return false;
return true;
@@ -135,6 +168,39 @@ export class GraphDrags {
this.follow();
}
/** Sizes the node in the model, for the preview: its far corner on the grid when snapping, as a group's. */
private previewCorner(g: Point): void {
const drag = this.corner!;
const dx = g.x - drag.pointer.x;
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return;
drag.moved = true;
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (!node) return;
const size = resizeSize(drag.start, dx, dy, this.host.snapping() ? GRID_STEP : 0, drag.min);
node.size = {
w: Math.round(Math.max(drag.min.w, size.w) - drag.chrome.w),
h: Math.round(Math.max(drag.min.h, size.h) - drag.chrome.h),
};
this.follow();
}
/** Puts the node's size back and records it as one undo step. False when no corner was dragged. */
releaseCorner(): boolean {
const drag = this.corner;
if (!drag) return false;
this.corner = null;
cancelFrameWork(this.frameKey);
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
const assetId = this.host.assetId();
if (!drag.moved || !node?.size || !assetId) return true;
const size = node.size;
if (drag.before) node.size = drag.before;
else delete node.size;
this.host.state.execute(new ResizeNodeCommand(assetId, node.id, size));
return true;
}
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
releaseBox(): boolean {
const drag = this.box;
+5
View File
@@ -6,6 +6,11 @@ import type { Widget } from '../../ui';
*/
export interface NodeFace {
readonly widget: Widget;
/**
* The least it can be dragged to, for a face whose node has a corner to resize it by (#385); the
* widget's `measure()` is its size until then.
*/
readonly minSize?: { w: number; h: number };
/** After every change to the model: the widget shows the node as it now is. */
sync(): void;
/** A frame on while the node is in the picture. Whether it still moves. */
+348
View File
@@ -0,0 +1,348 @@
/**
* The curve of a Parametric EQ (#367) and of a Filter (#385): what its bands do together, worked out
* as Web Audio plays them (`parametricEq.ts`). A band is dragged for frequency and gain and wheeled
* for its Q. 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 may be drawn behind the curve (#374).
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
import {
dbToY,
type EqArea,
EQ_RANGES,
eqCurvePoints,
fillShapes,
frequencyToX,
hasGain,
hasQ,
MAX_EQ_BANDS,
type PassBand,
xToFrequency,
yToDb,
} from '../../core/soundfx/parametricEq';
import type { Spectrum } from '../../viewport/spectrum';
import { crispText, pressCount, Theme, textStyle, Widget } from '../../ui';
import { ViewTween } from '../viewTween';
import {
apartLabels,
bandAt,
EQ_GRID_FREQUENCIES,
eqGridDb,
handleOf,
SPECTRUM_FLOOR_DB,
spectrumPeak,
spectrumYs,
wheeledQ,
wheeledRange,
} from './eqView';
/** The least the curve shrinks to; otherwise it takes what its panel leaves it. */
const MIN_CURVE_WIDTH = 160;
const MIN_CURVE_HEIGHT = 80;
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;
/** Pixels kept between two labels on an axis; one that comes closer is left out (#385). */
const LABEL_GAP = 4;
/** What the curve asks of the panel around it. */
export interface CurveHost {
bands(): readonly PassBand[];
range(): number;
zoom(range: number): void;
readonly picked: number;
pick(index: number): void;
/** A band changed as a drag goes, written into the node for a preview. */
drag(index: number, change: Partial<PassBand>): void;
/** The drag is over: its undo step. */
drop(): void;
/** A band changed as an undo step of its own. */
set(index: number, change: Partial<PassBand>): void;
/** A double-click on the free curve; false when the panel takes no band there. */
addBand(frequency: number, gain: number): boolean;
/** A right-click on a band; false when the band stays. */
removeBand(index: number): boolean;
}
export class BandCurve 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 dragging: { 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: CurveHost) {
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 };
}
/** The axis labels drawn, for the checks: those that would run into another are left out. */
get shownLabels(): { frequencies: string[]; dbs: string[] } {
const shown = (texts: Text[]) => texts.filter((t) => t.visible).map((t) => t.text);
return { frequencies: shown(this.labels), dbs: shown(this.dbLabels) };
}
/** 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 && this.host.removeBand(index)) e.stopPropagation();
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.dragging = { index, startX: e.global.x, startY: e.global.y, moved: false };
return;
}
// A double-click on the free curve may put a band there; a single one lets go of the picked one.
if (pressCount(e, this) === 2) {
const frequency = Math.round(xToFrequency(at.x, this.area));
if (this.host.addBand(frequency, Math.round(yToDb(at.y, this.area) * 10) / 10)) return;
}
this.host.pick(-1);
}
private move(e: FederatedPointerEvent): void {
const drag = this.dragging;
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 band = this.host.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.drag(drag.index, hasGain(band.type) ? { frequency, gain } : { frequency });
}
private release(): void {
const drag = this.dragging;
this.dragging = null;
if (drag?.moved) this.host.drop();
}
/**
* 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.set(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();
const frequencySpans = EQ_GRID_FREQUENCIES.map(({ hz }, i) => {
const x = Math.round(frequencyToX(hz, area)) + 0.5;
this.grid.moveTo(x, 0).lineTo(x, this.h);
const text = this.labels[i]!;
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
return { from: text.x, to: text.x + text.width };
});
apartLabels(frequencySpans, LABEL_GAP).forEach((shown, i) => (this.labels[i]!.visible = shown));
// 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());
const dbSpans: { from: number; to: number }[] = [];
const placed: Text[] = [];
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);
dbSpans.push({ from: text.y, to: text.y + text.height });
placed.push(text);
});
apartLabels(dbSpans, 0).forEach((shown, i) => (placed[i]!.visible = shown));
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]!);
}
@@ -1,24 +1,22 @@
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';
import { CURVE_NODE_MIN, type CurvePanel } from './CurvePanel';
/**
* 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.
* A Parametric EQ node (#381) or a Filter node (#385) drawn as its editor: the panel of its tab,
* worked right in the graph, with the spectrum behind the curve while the node is in the picture.
* It can be dragged to another size (#385).
*/
export class EqFace implements NodeFace {
readonly widget: EqPanel;
export class CurveFace implements NodeFace {
readonly minSize = CURVE_NODE_MIN;
private listening = false;
constructor(
state: EditorState,
readonly widget: CurvePanel,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new EqPanel(state, true);
}
) {}
sync(): void {
this.widget.show(this.assetId, this.nodeId);
+353
View File
@@ -0,0 +1,353 @@
/**
* What the editors of a Parametric EQ (#367) and of a Filter (#385) share: the curve, the numbers of
* the picked band in a row under it and, in a tab, a line of help. A tab shows the panel, and so
* does the node in the graph (#381), at the size the node was dragged to (#385).
*
* Too little room leaves things out rather than cutting them (#385): the row comes in only once the
* curve keeps `CURVE_ROOM` above it, the help only once the row has come in too, and a group of the
* row that does not fit is not drawn – the one on the right goes first.
*/
import type { FederatedPointerEvent } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import type { FxPassFilter } from '../../core/soundfx/nodes';
import {
EQ_FREQUENCY,
EQ_Q,
EQ_RANGE_PARAM,
EQ_SLOPES,
eqRangeOf,
hasGain,
hasQ,
hasSlope,
type PassBand,
} from '../../core/soundfx/parametricEq';
import {
Dropdown,
HStack,
Label,
Menu,
NumberField,
type Size,
Spacer,
Theme,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import { BandCurve, type CurveHost } from './BandCurve';
import { fittingCount, shownRows } from './eqView';
/** The height the curve keeps before the row under it comes in (#385). */
export const CURVE_ROOM = 120;
/** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */
export const CURVE_NODE_MIN: Size = { w: 240, h: 120 };
/** A row of groups, drawn from the left as far as they fit; the rest are not drawn at all (#385). */
class FittingRow extends HStack {
private readonly names: string[] = [];
private groups(): Widget[] {
return this.children.filter((c): c is Widget => c instanceof Widget);
}
addGroup(name: string, group: HStack): HStack {
this.names.push(name);
return this.add(group);
}
override measure(): Size {
const sizes = this.groups().map((group) => group.measure());
const w = sizes.reduce((sum, size) => sum + size.w, 0) + Math.max(0, sizes.length - 1) * this.gap;
const h = Math.max(0, ...sizes.map((size) => size.h));
return {
w: Math.max(this.minWidth, w + this.padding * 2),
h: Math.max(this.minHeight, h + this.padding * 2),
};
}
override layout(): void {
const groups = this.groups();
const fit = fittingCount(
groups.map((group) => group.measure().w),
this.gap,
this.w - this.padding * 2,
);
groups.forEach((group, i) => (group.visible = i < fit));
super.layout();
}
/** The groups drawn, by name – the check scripts read it. */
get shown(): string[] {
return this.names.filter((_, i) => this.groups()[i]?.visible);
}
}
export interface CurvePanelOptions {
/** What the panel is at in a node until the node is dragged to another size. */
nodeSize: Size;
types: readonly { value: FxPassFilter; label: string }[];
gain: { min: number; max: number };
help: string;
/** A group before the type, and one at the right end of the row. */
lead?: Widget;
end?: Widget;
}
export abstract class CurvePanel extends VStack implements CurveHost {
readonly curve: BandCurve;
protected assetId = '';
protected nodeId = '';
picked = -1;
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
protected readonly row = new FittingRow({ gap: Theme.gap, align: 'center' });
private readonly help: Label | null;
/** What the node held before a drag or a scrub began – its undo step's other side. */
private readonly before = new Map<string, PropertyValue | undefined>();
private readonly perFrame: FramePreview;
constructor(
protected readonly state: EditorState,
readonly inNode: boolean,
private readonly options: CurvePanelOptions,
frameName: string,
) {
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
this.perFrame = new FramePreview(state, frameName);
this.curve = new BandCurve(this);
const numbers = (change: (value: number) => Partial<PassBand>) => ({
onInput: (value: number) => this.drag(this.picked, change(value)),
onChange: (value: number) => this.set(this.picked, change(value)),
});
this.typeButton = new Dropdown('—', { 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: options.gain.min,
max: options.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());
if (options.lead) this.group('Band', options.lead);
for (const [name, widget] of [
['Type', this.typeButton],
['Hz', this.frequencyField],
['dB', this.gainField],
['Q', this.qField],
['Slope', this.slopeButton],
] as const)
this.group(name, new Label(name, { style: 'small' }), widget);
if (options.end) {
// The last group takes what is left, so what it holds sits at the right end.
const spacer = new Spacer();
const end = this.group('End', spacer, options.end);
end.flex = 1;
spacer.flex = 1;
}
this.help = inNode ? null : new Label(options.help, { style: 'muted' });
this.add(this.curve, 1);
this.add(this.row);
if (this.help) this.add(this.help);
}
/** A group of the row: a name and what it names, drawn or left out together. */
private group(name: string, ...widgets: Widget[]): HStack {
const group = new HStack({ gap: Theme.gap, align: 'center' });
for (const widget of widgets) group.add(widget);
return this.row.addGroup(name, group);
}
override measure(): Size {
return this.inNode ? this.options.nodeSize : super.measure();
}
override layout(): void {
const rows = this.help ? [this.row, this.help] : [this.row];
const shown = shownRows(
this.h - this.padding * 2,
CURVE_ROOM,
rows.map((row) => row.measure().h),
this.gap,
);
rows.forEach((row, i) => (row.visible = i < shown));
super.layout();
}
/** What the panel draws under the curve, for the checks: the groups of the row and the help. */
get shownRows(): { row: string[]; help: boolean } {
return { row: this.row.visible ? this.row.shown : [], help: this.help?.visible ?? false };
}
/** Points the panel at a node; what was 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.clear();
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;
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
protected abstract node(): GraphNode | null;
abstract bands(): PassBand[];
abstract drag(index: number, change: Partial<PassBand>): void;
abstract drop(): void;
abstract set(index: number, change: Partial<PassBand>): void;
addBand(_frequency: number, _gain: number): boolean {
return false;
}
removeBand(_index: number): boolean {
return false;
}
/** Which of the picked band's numbers are fed by a link and cannot be typed or dragged. */
protected locked(): { frequency: boolean; gain: boolean; q: boolean } {
return { frequency: false, gain: false, q: false };
}
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 values into the node as they are dragged; `commit` makes the undo step of them. */
protected preview(values: Record<string, PropertyValue>): void {
const node = this.node();
if (!node) return;
for (const [key, value] of Object.entries(values)) {
if (!this.before.has(key)) this.before.set(key, node.params[key]);
node.params[key] = value;
}
this.perFrame.request();
}
/** The values as one undo step, after what a drag wrote is put back; none when nothing changes. */
protected commit(values: Record<string, PropertyValue>, label: string): void {
this.perFrame.cancel();
const node = this.node();
if (!node) return;
this.restore(node);
const commands = Object.entries(values)
.filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(node.params[key]))
.map(([key, value]) => new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
if (commands.length === 0) this.state.preview();
else this.state.execute(oneStep(label, commands));
}
/** What a drag wrote as one undo step. */
protected commitPreview(label: string): void {
const node = this.node();
if (!node) return;
const values: Record<string, PropertyValue> = {};
for (const key of this.before.keys()) if (node.params[key] !== undefined) values[key] = node.params[key];
this.commit(values, label);
}
private restore(node: GraphNode): void {
for (const [key, value] of this.before) {
if (value === undefined) delete node.params[key];
else node.params[key] = value;
}
this.before.clear();
}
private openTypeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = this.options.types.map((type) => ({
label: type.label,
current: type.value === band.type,
onSelect: () => this.set(this.picked, { 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.set(this.picked, { slope }),
}));
new Menu(items).openBelow(this.slopeButton);
}
/** After every change to the model: the row shows the picked band as it now is. */
sync(): void {
if (!this.node()) return;
const bands = this.bands();
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const locked = this.locked();
this.typeButton.setText(this.options.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 && !locked.frequency));
this.gainField.setEnabled(Boolean(band && hasGain(band.type) && !locked.gain));
this.qField.setEnabled(Boolean(band && hasQ(band.type) && !locked.q));
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
this.curve.invalidate();
}
}
+27 -16
View File
@@ -1,27 +1,35 @@
/**
* 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).
* The tab of a Parametric EQ node (#370) or a Filter node (#385): its panel 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, #385).
*/
import { findGraphAsset, type Project } from '../../core/model/Project';
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
import { SoundFxFilter, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { Label, Spacer, VStack } from '../../ui';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
import type { CurvePanel } from './CurvePanel';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
const TITLES = { eq: 'Parametric EQ', filter: 'Filter' } as const;
/** 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' });
readonly panel: CurvePanel;
private readonly title: Label;
private readonly header = new TabHeader();
/** 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) {
constructor(
private readonly state: EditorState,
private readonly kind: 'eq' | 'filter' = 'eq',
) {
super();
this.panel = new EqPanel(state);
this.panel = kind === 'eq' ? new EqPanel(state) : new FilterPanel(state);
this.title = new Label(TITLES[kind], { style: 'bodyBold' });
this.header.add(this.title);
this.header.add(new Spacer(), 1);
this.add(this.header);
@@ -69,16 +77,16 @@ export class EqEditor extends VStack {
return this.panel.curve.hasSpectrum;
}
/** Delete in the tab takes the picked band – never the node the tab is about. */
/** Delete in an EQ's tab takes the picked band – never the node the tab is about. */
removePicked(): void {
this.panel.removePicked();
if (this.panel instanceof EqPanel) this.panel.removePicked();
}
private sync(): void {
const shown = this.panel.shownFor;
if (!shown) return;
this.panel.sync();
const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
const title = `${TITLES[this.kind]} – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
if (title === this.title.text) return;
this.title.setText(title);
this.relayout();
@@ -86,12 +94,15 @@ export class EqEditor extends VStack {
}
/**
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
* What an EQ's or a Filter's tab says: its asset's name, and which one it is when the asset has
* more than one of them.
*/
export function eqTabLabel(project: Project, assetId: string, nodeId: string): string {
const asset = findGraphAsset(project, assetId);
if (!asset) return '';
const eqs = asset.graph.nodes.filter((n) => n.type === SoundFxParametricEq.type);
const index = eqs.findIndex((n) => n.id === nodeId);
return eqs.length > 1 ? `${asset.name} · EQ ${index + 1}` : `${asset.name} · EQ`;
const type = asset?.graph.nodes.find((n) => n.id === nodeId)?.type;
if (!asset || !type) return '';
const noun = type === SoundFxFilter.type ? 'Filter' : 'EQ';
const same = asset.graph.nodes.filter((n) => n.type === type);
const index = same.findIndex((n) => n.id === nodeId);
return same.length > 1 ? `${asset.name} · ${noun} ${index + 1}` : `${asset.name} · ${noun}`;
}
+65 -568
View File
@@ -1,506 +1,58 @@
/**
* 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).
* The editor of a Parametric EQ node (#367): the curve of its bands and the numbers of the picked
* one. Its tab (#370) shows it, and since #381 so does the node itself in the graph. A double-click
* on the free curve adds a band, a right-click on one removes it. 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 { SoundFxParametricEq } from '../../core/soundfx/nodes';
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,
type PassBand,
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 { Label } from '../../ui';
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';
import { CurvePanel } from './CurvePanel';
/** 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. */
export class EqPanel extends CurvePanel {
private readonly bandLabel: Label;
private readonly count: Label;
/** The texts the row 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' },
),
constructor(state: EditorState, inNode = false) {
const bandLabel = new Label('', { style: 'small' });
const count = new Label('', { style: 'muted' });
super(
state,
inNode,
{
nodeSize: EQ_NODE_SIZE,
types: EQ_BAND_TYPES,
gain: EQ_GAIN,
help: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
lead: bandLabel,
end: count,
},
inNode ? 'eq-node' : 'eq-band',
);
this.bandLabel = bandLabel;
this.count = count;
}
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 {
protected node(): GraphNode | null {
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
}
@@ -509,119 +61,64 @@ export class EqPanel extends VStack implements EqHost {
return node ? eqBandsOf(node.params) : [];
}
range(): number {
return eqRangeOf(this.node()?.params ?? {});
/** A band's values as `withBandSet` holds them: a shape the EQ has not, like an all-pass, is none. */
private changed(index: number, change: Partial<PassBand>): EqBand[] {
return withBandSet(this.bands(), index, change as Partial<EqBand>);
}
zoom(range: number): void {
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
private commitBands(bands: EqBand[]): void {
this.commit({ [EQ_BANDS_PARAM]: eqBandsValue(bands) }, 'Set bands');
}
pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.sync();
drag(index: number, change: Partial<PassBand>): void {
if (index >= 0) this.preview({ [EQ_BANDS_PARAM]: eqBandsValue(this.changed(index, change)) });
}
/**
* 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();
drop(): void {
this.commitPreview('Set bands');
}
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));
set(index: number, change: Partial<PassBand>): void {
if (index >= 0) this.commitBands(this.changed(index, change));
}
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;
override addBand(frequency: number, gain: number): boolean {
const bands = this.bands();
if (bands.length >= MAX_EQ_BANDS) return false;
this.commitBands([...bands, newEqBand(frequency, gain)]);
this.pick(bands.length);
return true;
}
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));
override removeBand(index: number): boolean {
this.pick(-1);
this.commitBands(this.bands().filter((_, i) => i !== index));
return true;
}
/** 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));
if (this.picked >= 0) this.removeBand(this.picked);
}
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);
override close(): void {
super.close();
this.laidOut = '';
}
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';
override sync(): void {
if (!this.node()) return;
super.sync();
const count = `${this.bands().length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = this.picked >= 0 ? `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();
if (texts === this.laidOut) return;
this.laidOut = texts;
this.relayout();
// Its own size stays, so the row would not lay its labels out anew by itself.
this.row.relayout();
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The editor of a Filter node (#385): the curve of the Parametric EQ with the Filter as its one band,
* and its numbers under it. Its tab shows it, and so does the node itself in the graph. A number fed
* by a link is the link's: it can be neither typed nor dragged here.
*/
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import {
FILTER_ROWS,
FILTER_SETTINGS,
FILTER_TYPES,
filterBandOf,
SoundFxFilter,
} from '../../core/soundfx/nodes';
import type { PassBand } from '../../core/soundfx/parametricEq';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { CurvePanel } from './CurvePanel';
/** The panel in the node until it is dragged to another size: room for the whole row. */
export const FILTER_NODE_SIZE = { w: 560, h: 280 };
const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 };
/** A number on its step, within its row. */
function held(value: number, row: { min?: number; max?: number }, step: number): number {
const per = Math.round(1 / step);
return Math.max(row.min ?? value, Math.min(row.max ?? value, Math.round(value * per) / per));
}
export class FilterPanel extends CurvePanel {
constructor(state: EditorState, inNode = false) {
super(
state,
inNode,
{
nodeSize: FILTER_NODE_SIZE,
types: FILTER_TYPES,
gain: GAIN,
help: 'Drag: frequency, gain. Wheel: Q on the handle, else zoom.',
},
inNode ? 'filter-node' : 'filter-band',
);
}
protected node(): GraphNode | null {
return filterNodeOf(this.state.project, this.assetId, this.nodeId);
}
bands(): PassBand[] {
const node = this.node();
return node ? [filterBandOf(node.params)] : [];
}
/** Its one band stays picked. */
override pick(): void {}
protected override locked(): { frequency: boolean; gain: boolean; q: boolean } {
const wired = (key: string) => compressorWired(this.state.project, this.assetId, this.nodeId, key);
return {
frequency: wired(FILTER_ROWS.cutoff.key),
gain: wired(FILTER_ROWS.gain.key),
q: wired(FILTER_ROWS.q.key),
};
}
/** A change of the band as what the node holds, without the numbers a link feeds. */
private values(change: Partial<PassBand>): Record<string, PropertyValue> {
const locked = this.locked();
const values: Record<string, PropertyValue> = {};
if (change.frequency !== undefined && !locked.frequency)
values[FILTER_ROWS.cutoff.key] = held(change.frequency, FILTER_ROWS.cutoff, 1);
if (change.gain !== undefined && !locked.gain)
values[FILTER_ROWS.gain.key] = held(change.gain, GAIN, 0.1);
if (change.q !== undefined && !locked.q) values[FILTER_ROWS.q.key] = held(change.q, FILTER_ROWS.q, 0.001);
if (change.type !== undefined) values[FILTER_SETTINGS.type] = change.type;
if (change.slope !== undefined) values[FILTER_SETTINGS.slope] = String(change.slope);
return values;
}
drag(_index: number, change: Partial<PassBand>): void {
const values = this.values(change);
if (Object.keys(values).length > 0) this.preview(values);
}
drop(): void {
this.commitPreview('Set filter');
}
set(_index: number, change: Partial<PassBand>): void {
this.commit(this.values(change), 'Set filter');
}
}
/** The Filter node a panel is about, or null once it, or its asset, is gone. */
export function filterNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxFilter.type ? node : null;
}
+48 -3
View File
@@ -4,7 +4,7 @@
*/
import {
type EqArea,
type EqBand,
type PassBand,
dbToY,
EQ_Q,
EQ_RANGES,
@@ -17,13 +17,19 @@ import {
* Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line.
* A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach.
*/
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
export function handleOf(band: PassBand, area: EqArea): { x: number; y: number } {
const y = dbToY(hasGain(band.type) ? band.gain : 0, area);
return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) };
}
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number {
export function bandAt(
bands: readonly PassBand[],
x: number,
y: number,
area: EqArea,
radius: number,
): number {
let found = -1;
let best = radius * radius;
bands.forEach((band, i) => {
@@ -37,6 +43,45 @@ export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqA
return found;
}
/**
* How many of a row's groups fit into `room`, from the left (#385): those after them are not drawn
* at all, rather than cut or scrolled.
*/
export function fittingCount(widths: readonly number[], gap: number, room: number): number {
let used = 0;
for (let i = 0; i < widths.length; i++) {
used += (i > 0 ? gap : 0) + widths[i]!;
if (used > room) return i;
}
return widths.length;
}
/**
* How many of the rows below the curve a height has room for (#385): each comes in only once the
* curve keeps `curveRoom` above it, the first row first.
*/
export function shownRows(height: number, curveRoom: number, rows: readonly number[], gap: number): number {
let used = curveRoom;
for (let i = 0; i < rows.length; i++) {
used += gap + rows[i]!;
if (used > height) return i;
}
return rows.length;
}
/**
* Which labels along one axis are drawn (#385): each one `gap` clear of the last one drawn, in the
* order given. One that would run into it is left out rather than drawn over it.
*/
export function apartLabels(spans: readonly { from: number; to: number }[], gap: number): boolean[] {
let last: { from: number; to: number } | null = null;
return spans.map((span) => {
if (last && span.from < last.to + gap && span.to > last.from - gap) return false;
last = span;
return true;
});
}
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
export function wheeledQ(q: number, deltaY: number): number {
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
+8 -4
View File
@@ -1,15 +1,19 @@
import type { GraphNode } from '../../core/graph';
import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { CompressorFace } from './CompressorFace';
import { EqFace } from './EqFace';
import { CurveFace } from './CurveFace';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { GainFace } from './GainFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | 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);
if (node.type === SoundFxParametricEq.type)
return new CurveFace(new EqPanel(state, true), assetId, node.id);
if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id);
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
return null;
}
+17 -7
View File
@@ -1,27 +1,35 @@
/**
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
* Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset
* and the node (`eq:<asset>:<node>`).
* Parametric EQ (#370), a Compressor (#378) and a Filter (#385). A tab is a document named by its
* kind, the asset and the node (`eq:<asset>:<node>`).
*/
import type { GraphNode } from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
import { eqTabLabel } from './EqEditor';
import { eqNodeOf } from './EqPanel';
import { filterNodeOf } from './FilterPanel';
export type NodeTabKind = 'eq' | 'compressor';
export type NodeTabKind = 'eq' | 'compressor' | 'filter';
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor'];
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor', 'filter'];
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
[SoundFxParametricEq.type]: 'eq',
[SoundFxCompressor.type]: 'compressor',
[SoundFxFilter.type]: 'filter',
};
/** The kind of tab a node opens, or null for one without. */
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
const NODE_OF: Readonly<Record<NodeTabKind, typeof eqNodeOf>> = {
eq: eqNodeOf,
compressor: compressorNodeOf,
filter: filterNodeOf,
};
/** The node a tab is about, or null once it, or its asset, is gone. */
export function nodeOfTab(
project: Project,
@@ -29,9 +37,11 @@ export function nodeOfTab(
assetId: string,
nodeId: string,
): GraphNode | null {
return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId);
return NODE_OF[kind](project, assetId, nodeId);
}
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId);
return kind === 'compressor'
? compressorTabLabel(project, assetId, nodeId)
: eqTabLabel(project, assetId, nodeId);
}