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:
1 parent
17f47f6956
commit
c087993000
26 files changed
+1514
-656
No files matched your search
+17
-2
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user