Files
feuerwerk/src/editor/soundfx/EqEditor.ts
T
adminandClaude Opus 5.5 9166d4a56f Drop the Remove button below the EQ's curve (#370)
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>
2026-10-09 14:27:09 +02:00

527 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`;
}