Shape a sound with a Parametric EQ and its own curve editor (#367)
game-maker / gate (push) Successful in 4m17s
game-maker / suite (push) Successful in 16m14s

A sound FX or an instrument had a 5-Band-EQ with fixed bands and a
Filter with one band. The Parametric EQ holds up to ten bands of any
type, and a double-click on the node opens an editor that draws the
curve they make together.

- The bands are a list on the node (`bands`): type, frequency, gain,
  Q and slope. A hand-edited project is held to the editor's ranges
  and to ten bands.
- Each band compiles to the existing `pass` stage, so the audio core
  is unchanged. A peak or a shelf at 0 dB is left out.
- The curve is worked out in `parametricEq.ts` with the biquad
  formulas of the Web Audio spec, so the editor draws what is heard.
- The editor is a window that floats over the editor without a
  backdrop, so the tab's Play stays usable. A double-click adds a
  peak, a drag moves a band, the wheel turns its Q, a right-click
  removes it. Fields below the curve set the picked band. It opens in
  the middle of the screen and moves by its title bar.
- A drag or a scrub writes the node as a preview and makes one undo
  step on release. The panels hear of the preview once a frame, and
  the window lays itself out only when one of its texts changes.
- The area under the curve is filled as one shape for each side of
  0 dB. As a single shape it crossed itself and came apart.
- The curve works out each band's coefficients once and adds the sum
  up from the bands. A drag took 295 ms of script time in a profile
  before and 64 ms after.

Decided on my own:
- Bands are added by a double-click, so a single click can let go of
  the picked band without adding one.
- The cuts are labelled "Low cut" and "High cut" as in an EQ, not
  "High-pass" and "Low-pass" as on the Filter.
- The bands have no input, so no parameter or Envelope moves them.

Tests:
- Unit parametric-eq (new) and soundfx: 69 green. Counter-probe with
  no stages emitted: the compile test red. Counter-probe with one
  fill shape: the fill test red.
- e2e 34 (melektaus-atx): ALL PASSED, 96 checks (3 new).
- Counter-probe e2e: no stages emitted, the playing check red.
- Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 12:00:43 +02:00
1 parent 204c4a41e8
commit 1f13794b0c
9 files changed
+1105 -4

No files matched your search

+30
View File
@@ -1,5 +1,6 @@
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { currentNodePalette } from '../graph/NodePalette';
import { currentEqEditor } from '../soundfx/EqEditor';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -114,6 +115,35 @@ export function graphHooks({ state, editor }: HookContext) {
/** The header's parameter values, and setting one as its field would. */
params: () => editor.soundFxEditor.previewValues,
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
},
/** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */
eq: {
shown: () => currentEqEditor()?.shownFor ?? null,
bands: () => currentEqEditor()?.bands() ?? null,
picked: () => currentEqEditor()?.picked ?? null,
handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null,
pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null,
fields: () => {
const eq = currentEqEditor();
return (
eq && {
type: eq.typeButton.text,
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
gain: eq.gainField.enabled ? eq.gainField.value : null,
q: eq.qField.enabled ? eq.qField.value : null,
frequency: eq.frequencyField.value,
}
);
},
typeAt: () => {
const eq = currentEqEditor();
return eq ? centreOf(eq.typeButton) : null;
},
closeAt: () => {
const eq = currentEqEditor();
return eq ? centreOf(eq.closeButton) : null;
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
+6
View File
@@ -30,6 +30,8 @@ import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import { openEqEditor } from '../soundfx/EqEditor';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing';
@@ -669,6 +671,10 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SoundFxParametricEq.type) {
openEqEditor(this.state, this.assetId, node.id);
return true;
}
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
if (node.type === ROUTED_ASSET) {
const asset = findAsset(this.state.project, node.id);
+554
View File
@@ -0,0 +1,554 @@
/**
* The editor of a Parametric EQ node (#367): a window that floats over the editor and leaves it
* usable, so the tab's Play can be pressed while the bands are set. 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 } 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 {
Button,
crispText,
Dropdown,
HStack,
Label,
Menu,
NumberField,
Popup,
pressCount,
Spacer,
Theme,
textStyle,
ui,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import {
bandAt,
dbToY,
type EqArea,
EQ_GRID_DB,
EQ_GRID_FREQUENCIES,
fillShapes,
frequencyToX,
handleOf,
wheeledQ,
xToFrequency,
yToDb,
} from './eqView';
const CURVE_WIDTH = 640;
const CURVE_HEIGHT = 260;
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 window 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 = CURVE_WIDTH;
this.minHeight = 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]!);
}
}
/**
* One window for the node last double-clicked. It closes with its node, with the project, or by its
* own button.
*/
export class EqWindow extends Popup 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 HStack({ gap: Theme.gap, align: 'center' });
private readonly bandLabel = new Label('', { style: 'small' });
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
readonly removeButton: Button;
readonly closeButton: Button;
private readonly unsubscribe: (() => void)[] = [];
private readonly perFrame: FramePreview;
/** The texts the window was last laid out for; a drag changes only numbers and needs no layout. */
private laidOut = '';
private grab: { x: number; y: number } | null = null;
constructor(private readonly state: EditorState) {
super({ backdrop: false, padding: Theme.padding * 2, gap: Theme.padding });
this.perFrame = new FramePreview(state, 'eq-band');
this.curve = new EqCurve(this);
this.closeButton = new Button('Close', { variant: 'ghost', onClick: () => this.close() });
const header = this.header;
header.add(this.title);
header.add(this.count);
header.add(new Spacer(), 1);
header.add(this.closeButton);
header.cursor = 'move';
header.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.target !== header) return;
this.grab = { x: e.global.x - this.x, y: e.global.y - this.y };
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!this.grab) return;
const screen = ui().app.screen;
this.position.set(
Math.round(Math.max(0, Math.min(screen.width - this.w, e.global.x - this.grab.x))),
Math.round(Math.max(0, Math.min(screen.height - this.h, e.global.y - this.grab.y))),
);
});
this.onPointerRelease(() => (this.grab = null));
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 })),
});
this.removeButton = new Button('Remove', { variant: 'ghost', onClick: () => this.removePicked() });
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);
}
row.add(new Spacer(), 1);
row.add(this.removeButton);
this.content.add(header);
this.content.add(this.curve);
this.content.add(row);
this.content.add(
new Label(
`Double-click to add a band (up to ${MAX_EQ_BANDS}). Drag a band for its frequency and gain, turn the mouse wheel for its Q, right-click it to remove it.`,
{ style: 'muted' },
),
);
}
/** Opens for a node, or turns an open window to it. */
show(assetId: string, nodeId: string): void {
const other = assetId !== this.assetId || nodeId !== this.nodeId;
this.assetId = assetId;
this.nodeId = nodeId;
if (other) this.picked = this.bands().length > 0 ? 0 : -1;
if (!this.isOpen) {
this.unsubscribe.push(
this.state.on('project', () => this.sync()),
this.state.on('load', () => this.close()),
);
this.openCentered();
}
this.sync();
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.isOpen ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
override close(): void {
for (const off of this.unsubscribe.splice(0)) off();
this.perFrame.cancel();
this.laidOut = '';
super.close();
}
private node(): GraphNode | null {
return (
findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find((n) => n.id === this.nodeId) ?? null
);
}
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));
}
private 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);
}
/** Follows the node: gone, the window goes with it. */
private sync(): void {
if (!this.isOpen) return;
const node = this.node();
if (!node) {
this.close();
return;
}
const bands = eqBandsOf(node.params);
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
const title = `Parametric EQ – ${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)));
this.removeButton.setEnabled(Boolean(band));
const texts = [title, count, bandLabel].join('\n');
if (texts !== this.laidOut) {
this.laidOut = texts;
this.header.relayout();
this.content.relayout();
}
this.curve.invalidate();
}
}
let eqWindow: EqWindow | null = null;
/** Opens the editor of a Parametric EQ node; one window, turned to whichever node asked last. */
export function openEqEditor(state: EditorState, assetId: string, nodeId: string): EqWindow {
eqWindow ??= new EqWindow(state);
eqWindow.show(assetId, nodeId);
return eqWindow;
}
/** The open window, for the check scripts. */
export function currentEqEditor(): EqWindow | null {
return eqWindow?.isOpen ? eqWindow : null;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* How the Parametric EQ editor (#367) lays its curve onto a picture: frequency to the right in
* octaves, from 20 Hz to 20 kHz, and dB up, the same distance either side of 0.
*/
import { type EqBand, EQ_FREQUENCY, EQ_GAIN, EQ_Q, hasGain } from '../../core/soundfx/parametricEq';
export interface EqArea {
w: number;
h: number;
}
/** The dB the picture shows above and below 0. */
export const EQ_VIEW_DB = EQ_GAIN.max;
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
export const frequencyToX = (frequency: number, area: EqArea): number =>
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
export const xToFrequency = (x: number, area: EqArea): number =>
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / EQ_VIEW_DB) * (area.h / 2);
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB;
/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
return { x: frequencyToX(band.frequency, area), y: dbToY(hasGain(band.type) ? band.gain : 0, area) };
}
/** 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 {
let found = -1;
let best = radius * radius;
bands.forEach((band, i) => {
const at = handleOf(band, area);
const d = (at.x - x) ** 2 + (at.y - y) ** 2;
if (d <= best) {
best = d;
found = i;
}
});
return found;
}
/** 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);
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
}
/**
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
* line, and its fill would come apart.
*/
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
const shapes: number[][] = [];
let shape = [xs[0]!, zero];
for (let i = 0; i < xs.length; i++) {
const after = ys[i]! - zero;
const before = i > 0 ? ys[i - 1]! - zero : 0;
if (before * after < 0) {
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
shapes.push([...shape, x, zero]);
shape = [x, zero];
}
shape.push(xs[i]!, ys[i]!);
}
shapes.push([...shape, xs.at(-1)!, zero]);
return shapes;
}
/** The lines the picture is ruled with, and what they say. */
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
{ hz: 20, label: '20' },
{ hz: 50, label: '50' },
{ hz: 100, label: '100' },
{ hz: 200, label: '200' },
{ hz: 500, label: '500' },
{ hz: 1000, label: '1k' },
{ hz: 2000, label: '2k' },
{ hz: 5000, label: '5k' },
{ hz: 10000, label: '10k' },
{ hz: 20000, label: '20k' },
];
export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18];