A right-click on a band's handle removes it, and so does Delete in the EQ's tab. The button said the same a third time and took room in the row of numbers. - The row below the curve holds Type, Hz, dB, Q and Slope only. - `removePicked` stays, the right-click and Delete use it. Tests: - Typecheck, ESLint, Prettier: clean. - e2e 34 never pressed the button, nothing to change there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
/**
|
||
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the
|
||
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
||
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
||
* undo step.
|
||
*/
|
||
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 {
|
||
type EqBand,
|
||
EQ_BAND_TYPES,
|
||
EQ_BANDS_PARAM,
|
||
EQ_FREQUENCY,
|
||
EQ_GAIN,
|
||
EQ_Q,
|
||
EQ_SLOPES,
|
||
eqBandResponse,
|
||
eqBandsOf,
|
||
eqBandsValue,
|
||
hasGain,
|
||
hasQ,
|
||
hasSlope,
|
||
isEqBandHeard,
|
||
MAX_EQ_BANDS,
|
||
newEqBand,
|
||
withBandSet,
|
||
} from '../../core/soundfx/parametricEq';
|
||
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||
import {
|
||
crispText,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
NumberField,
|
||
pressCount,
|
||
Spacer,
|
||
Theme,
|
||
textStyle,
|
||
VStack,
|
||
Widget,
|
||
} from '../../ui';
|
||
import { FramePreview } from '../framePreview';
|
||
import type { EditorState } from '../state';
|
||
import { TabHeader } from '../TabHeader';
|
||
import {
|
||
bandAt,
|
||
dbToY,
|
||
type EqArea,
|
||
EQ_GRID_DB,
|
||
EQ_GRID_FREQUENCIES,
|
||
fillShapes,
|
||
frequencyToX,
|
||
handleOf,
|
||
wheeledQ,
|
||
xToFrequency,
|
||
yToDb,
|
||
} from './eqView';
|
||
|
||
/** The least the curve shrinks to; otherwise it takes the tab. */
|
||
const MIN_CURVE_WIDTH = 320;
|
||
const MIN_CURVE_HEIGHT = 160;
|
||
const HANDLE_RADIUS = 7;
|
||
const GRAB_RADIUS = HANDLE_RADIUS + 4;
|
||
/** Pixels of travel before a press on a band becomes a drag. */
|
||
const DRAG_THRESHOLD = 3;
|
||
/** Points the curve is drawn through, evenly in octaves. */
|
||
const CURVE_STEPS = 320;
|
||
const BAND_COLORS = [
|
||
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
||
];
|
||
|
||
/** What the curve asks of the tab around it. */
|
||
interface EqHost {
|
||
bands(): EqBand[];
|
||
picked: number;
|
||
pick(index: number): void;
|
||
preview(bands: EqBand[]): void;
|
||
commit(bands: EqBand[]): void;
|
||
cancel(): void;
|
||
}
|
||
|
||
class EqCurve extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly grid = new Graphics();
|
||
private readonly lines = new Graphics();
|
||
private readonly handles = new Graphics();
|
||
private readonly labels: Text[] = [];
|
||
private readonly numbers: Text[] = [];
|
||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||
|
||
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.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 }));
|
||
for (const db of EQ_GRID_DB)
|
||
this.labels.push(crispText({ text: db > 0 ? `+${db}` : `${db}`, 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.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 };
|
||
}
|
||
|
||
/** Where a band's handle is on screen – the check scripts aim with it. */
|
||
handleGlobal(index: number): { x: number; y: number } | null {
|
||
const band = this.host.bands()[index];
|
||
if (!band) return null;
|
||
const at = handleOf(band, this.area);
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + at.x, y: origin.y + at.y };
|
||
}
|
||
|
||
/** Where a frequency and a gain are on screen. */
|
||
pointGlobal(frequency: number, db: number): { x: number; y: number } {
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + frequencyToX(frequency, this.area), y: origin.y + dbToY(db, this.area) };
|
||
}
|
||
|
||
private local(e: FederatedPointerEvent): { x: number; y: number } {
|
||
return this.toLocal(e.global);
|
||
}
|
||
|
||
private press(e: FederatedPointerEvent): void {
|
||
const at = this.local(e);
|
||
const bands = this.host.bands();
|
||
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||
if (e.button === 2) {
|
||
if (index >= 0) {
|
||
this.host.pick(-1);
|
||
this.host.commit(bands.filter((_, i) => i !== index));
|
||
}
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
if (index >= 0) {
|
||
this.host.pick(index);
|
||
this.drag = { index, startX: e.global.x, startY: e.global.y, moved: false };
|
||
return;
|
||
}
|
||
// A double-click on the free curve puts a band there; a single one lets go of the picked one.
|
||
if (pressCount(e, 'eq-curve') === 2 && bands.length < MAX_EQ_BANDS) {
|
||
const band = newEqBand(
|
||
Math.round(xToFrequency(at.x, this.area)),
|
||
Math.round(yToDb(at.y, this.area) * 10) / 10,
|
||
);
|
||
this.host.commit([...bands, band]);
|
||
this.host.pick(bands.length);
|
||
return;
|
||
}
|
||
this.host.pick(-1);
|
||
}
|
||
|
||
private move(e: FederatedPointerEvent): void {
|
||
const drag = this.drag;
|
||
if (!drag) return;
|
||
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
|
||
return;
|
||
drag.moved = true;
|
||
const at = this.local(e);
|
||
const bands = this.host.bands();
|
||
const band = bands[drag.index];
|
||
if (!band) return;
|
||
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
|
||
this.host.preview(
|
||
withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }),
|
||
);
|
||
}
|
||
|
||
private release(): void {
|
||
const drag = this.drag;
|
||
this.drag = null;
|
||
if (!drag?.moved) return;
|
||
this.host.commit(this.host.bands());
|
||
}
|
||
|
||
/** The wheel over a band, or anywhere while one is picked, turns its Q. */
|
||
private wheel(e: FederatedWheelEvent): void {
|
||
const at = this.toLocal(e.global);
|
||
const bands = this.host.bands();
|
||
const under = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||
const index = under >= 0 ? under : this.host.picked;
|
||
const band = bands[index];
|
||
if (!band || !hasQ(band.type) || e.deltaY === 0) return;
|
||
e.stopPropagation();
|
||
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);
|
||
}
|
||
for (const db of EQ_GRID_DB) {
|
||
const y = Math.round(dbToY(db, area)) + 0.5;
|
||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||
const text = this.labels[label++]!;
|
||
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 });
|
||
|
||
// Each band once at every step; the sum is added up from them rather than worked out again.
|
||
const bands = this.host.bands();
|
||
this.lines.clear();
|
||
const xs = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => (i / CURVE_STEPS) * this.w);
|
||
const frequencies = xs.map((x) => xToFrequency(x, area));
|
||
const yOf = (db: number) => Math.max(-2, Math.min(this.h + 2, dbToY(db, area)));
|
||
const sum = new Array<number>(xs.length).fill(0);
|
||
bands.forEach((band, i) => {
|
||
const response = eqBandResponse(band);
|
||
const dbs = frequencies.map(response);
|
||
if (isEqBandHeard(band)) dbs.forEach((db, j) => (sum[j]! += db));
|
||
this.trace(xs, dbs.map(yOf));
|
||
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||
});
|
||
const ys = sum.map(yOf);
|
||
for (const shape of fillShapes(xs, ys, zero))
|
||
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||
this.trace(xs, ys);
|
||
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));
|
||
});
|
||
}
|
||
|
||
/** A path through the points. */
|
||
private trace(xs: readonly number[], ys: readonly number[]): void {
|
||
this.lines.moveTo(xs[0]!, ys[0]!);
|
||
for (let i = 1; i < xs.length; i++) this.lines.lineTo(xs[i]!, ys[i]!);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The tab of a Parametric EQ node (#370): one editor, pointed at the node whose tab is showing. It
|
||
* follows its node; once the node is gone the editor tells the tab row through `onGone`.
|
||
*/
|
||
export class EqEditor extends VStack implements EqHost {
|
||
readonly curve: EqCurve;
|
||
private assetId = '';
|
||
private nodeId = '';
|
||
picked = -1;
|
||
/** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */
|
||
private before: PropertyValue | undefined | null = null;
|
||
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
|
||
private readonly count = new Label('', { style: 'muted' });
|
||
private readonly header = new TabHeader();
|
||
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
|
||
private readonly bandLabel = new Label('', { style: 'small' });
|
||
readonly typeButton: Dropdown;
|
||
readonly slopeButton: Dropdown;
|
||
readonly frequencyField: NumberField;
|
||
readonly gainField: NumberField;
|
||
readonly qField: NumberField;
|
||
private readonly perFrame: FramePreview;
|
||
/** The texts the tab was last laid out for; a drag changes only numbers and needs no layout. */
|
||
private laidOut = '';
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.perFrame = new FramePreview(state, 'eq-band');
|
||
this.curve = new EqCurve(this);
|
||
this.header.add(this.title);
|
||
this.header.add(this.count);
|
||
this.header.add(new Spacer(), 1);
|
||
|
||
const numbers = (change: (value: number) => Partial<EqBand>) => ({
|
||
onInput: (value: number) => this.previewPicked(change(value)),
|
||
onChange: (value: number) => this.commitPicked(change(value)),
|
||
});
|
||
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
|
||
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
|
||
this.frequencyField = new NumberField(1000, {
|
||
width: 64,
|
||
min: EQ_FREQUENCY.min,
|
||
max: EQ_FREQUENCY.max,
|
||
step: 1,
|
||
decimals: 0,
|
||
...numbers((frequency) => ({ frequency })),
|
||
});
|
||
this.gainField = new NumberField(0, {
|
||
width: 52,
|
||
min: EQ_GAIN.min,
|
||
max: EQ_GAIN.max,
|
||
step: 0.1,
|
||
decimals: 1,
|
||
...numbers((gain) => ({ gain })),
|
||
});
|
||
this.qField = new NumberField(1, {
|
||
width: 52,
|
||
min: EQ_Q.min,
|
||
max: EQ_Q.max,
|
||
step: 0.01,
|
||
decimals: 3,
|
||
...numbers((q) => ({ q })),
|
||
});
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.add(this.bandLabel);
|
||
for (const [name, widget] of [
|
||
['Type', this.typeButton],
|
||
['Hz', this.frequencyField],
|
||
['dB', this.gainField],
|
||
['Q', this.qField],
|
||
['Slope', this.slopeButton],
|
||
] as const) {
|
||
row.add(new Label(name, { style: 'small' }));
|
||
row.add(widget);
|
||
}
|
||
|
||
this.body.add(this.curve, 1);
|
||
this.body.add(row);
|
||
this.body.add(
|
||
new Label('Double-click: add a band. Drag: frequency and gain. Wheel: Q. Right-click: remove.', {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
this.add(this.header);
|
||
this.add(this.body, 1);
|
||
state.on('project', () => this.sync());
|
||
}
|
||
|
||
/** Points the tab at a node; the band picked stays when it is the same node again. */
|
||
show(assetId: string, nodeId: string): void {
|
||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||
this.perFrame.cancel();
|
||
this.before = null;
|
||
this.assetId = assetId;
|
||
this.nodeId = nodeId;
|
||
this.picked = this.bands().length > 0 ? 0 : -1;
|
||
}
|
||
this.sync();
|
||
}
|
||
|
||
/** The tab is closed: the editor lets go of its node. */
|
||
close(): void {
|
||
this.perFrame.cancel();
|
||
const node = this.node();
|
||
if (node) this.restore(node);
|
||
this.assetId = '';
|
||
this.nodeId = '';
|
||
this.picked = -1;
|
||
this.laidOut = '';
|
||
}
|
||
|
||
get shownFor(): { assetId: string; nodeId: string } | null {
|
||
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
||
}
|
||
|
||
private node(): GraphNode | null {
|
||
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
||
}
|
||
|
||
bands(): EqBand[] {
|
||
const node = this.node();
|
||
return node ? eqBandsOf(node.params) : [];
|
||
}
|
||
|
||
pick(index: number): void {
|
||
if (this.picked === index) return;
|
||
this.picked = index;
|
||
this.sync();
|
||
}
|
||
|
||
/**
|
||
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
|
||
* panels hear of it once a frame, however fast the mouse reports.
|
||
*/
|
||
preview(bands: EqBand[]): void {
|
||
const node = this.node();
|
||
if (!node) return;
|
||
if (this.before === null) this.before = node.params[EQ_BANDS_PARAM];
|
||
node.params[EQ_BANDS_PARAM] = eqBandsValue(bands);
|
||
this.perFrame.request();
|
||
}
|
||
|
||
commit(bands: EqBand[]): void {
|
||
this.perFrame.cancel();
|
||
const node = this.node();
|
||
if (!node) return;
|
||
this.restore(node);
|
||
const value = eqBandsValue(bands);
|
||
if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) {
|
||
this.state.preview();
|
||
return;
|
||
}
|
||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value));
|
||
}
|
||
|
||
cancel(): void {
|
||
this.perFrame.cancel();
|
||
const node = this.node();
|
||
if (node) this.restore(node);
|
||
this.state.preview();
|
||
}
|
||
|
||
private restore(node: GraphNode): void {
|
||
if (this.before === null) return;
|
||
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
|
||
else node.params[EQ_BANDS_PARAM] = this.before;
|
||
this.before = null;
|
||
}
|
||
|
||
private previewPicked(change: Partial<EqBand>): void {
|
||
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
|
||
}
|
||
|
||
private commitPicked(change: Partial<EqBand>): void {
|
||
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
|
||
}
|
||
|
||
/** Delete in the tab takes the picked band – never the node the tab is about. */
|
||
removePicked(): void {
|
||
const index = this.picked;
|
||
if (index < 0) return;
|
||
this.picked = -1;
|
||
this.commit(this.bands().filter((_, i) => i !== index));
|
||
}
|
||
|
||
private openTypeMenu(): void {
|
||
const band = this.bands()[this.picked];
|
||
if (!band) return;
|
||
const items = EQ_BAND_TYPES.map((type) => ({
|
||
label: type.label,
|
||
current: type.value === band.type,
|
||
onSelect: () => this.commitPicked({ type: type.value }),
|
||
}));
|
||
new Menu(items).openBelow(this.typeButton);
|
||
}
|
||
|
||
private openSlopeMenu(): void {
|
||
const band = this.bands()[this.picked];
|
||
if (!band) return;
|
||
const items = EQ_SLOPES.map((slope) => ({
|
||
label: `${slope} dB/oct`,
|
||
current: slope === band.slope,
|
||
onSelect: () => this.commitPicked({ slope }),
|
||
}));
|
||
new Menu(items).openBelow(this.slopeButton);
|
||
}
|
||
|
||
private sync(): void {
|
||
const node = this.node();
|
||
if (!node) return;
|
||
const bands = eqBandsOf(node.params);
|
||
if (this.picked >= bands.length) this.picked = -1;
|
||
const band = bands[this.picked];
|
||
const title = `Parametric EQ – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
|
||
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
|
||
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
|
||
this.title.setText(title);
|
||
this.count.setText(count);
|
||
this.bandLabel.setText(bandLabel);
|
||
this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—');
|
||
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
|
||
this.frequencyField.setNumber(band?.frequency ?? 1000);
|
||
this.gainField.setNumber(band?.gain ?? 0);
|
||
this.qField.setNumber(band?.q ?? 1);
|
||
this.typeButton.setEnabled(Boolean(band));
|
||
this.frequencyField.setEnabled(Boolean(band));
|
||
this.gainField.setEnabled(Boolean(band && hasGain(band.type)));
|
||
this.qField.setEnabled(Boolean(band && hasQ(band.type)));
|
||
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
|
||
const texts = [title, count, bandLabel].join('\n');
|
||
if (texts !== this.laidOut) {
|
||
this.laidOut = texts;
|
||
this.relayout();
|
||
}
|
||
this.curve.invalidate();
|
||
}
|
||
}
|
||
|
||
/** The Parametric EQ node a tab is about, or null once it, or its asset, is gone. */
|
||
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
|
||
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
|
||
return node?.type === SoundFxParametricEq.type ? node : null;
|
||
}
|
||
|
||
/**
|
||
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
|
||
*/
|
||
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`;
|
||
}
|