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

+29
View File
@@ -16,6 +16,7 @@ import {
toneCutoff,
} from './saturation';
import { PHASER_STAGES } from './phaser';
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -592,6 +593,33 @@ export const SoundFxEq: SoundFxNodeDef = effect(
'blend',
);
export const SoundFxParametricEq: SoundFxNodeDef = effect(
{
type: 'SoundFxParametricEq',
label: 'Parametric EQ',
category: 'sound',
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope.${BLENDS}`,
inputs: [audioIn],
outputs: [audioOut],
params: [],
emit: (sounds, _amounts, settings) =>
withStages(
arriving(sounds),
eqBandsOf(settings)
.filter(isEqBandHeard)
.map((band) => ({
kind: 'pass',
filter: band.type,
stages: eqStagesOf(band),
cutoff: { value: band.frequency },
q: { value: band.q },
gain: { value: hasGain(band.type) ? band.gain : 0 },
})),
),
},
'blend',
);
/** The Q of a filter with no bump at its cutoff, as a factor. */
export const FLAT_Q = Math.SQRT1_2;
@@ -1209,6 +1237,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxGain,
SoundFxPitch,
SoundFxEq,
SoundFxParametricEq,
SoundFxFilter,
SoundFxMix,
SoundFxDelay,
+215
View File
@@ -0,0 +1,215 @@
/**
* The bands of a Parametric EQ (#367): up to `MAX_EQ_BANDS` filters in a row, kept as a list on the
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
* editor draws what is heard.
*/
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
import type { FxPassFilter } from './nodes';
export const EQ_BANDS_PARAM = 'bands';
export const MAX_EQ_BANDS = 10;
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
export type EqSlope = 12 | 24 | 48;
export interface EqBand {
type: EqBandType;
frequency: number;
gain: number;
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
q: number;
slope: EqSlope;
}
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
{ value: 'peaking', label: 'Peaking' },
{ value: 'lowshelf', label: 'Low shelf' },
{ value: 'highshelf', label: 'High shelf' },
{ value: 'highpass', label: 'Low cut' },
{ value: 'lowpass', label: 'High cut' },
{ value: 'bandpass', label: 'Band-pass' },
{ value: 'notch', label: 'Notch' },
];
export const EQ_SLOPES: readonly EqSlope[] = [12, 24, 48];
export const EQ_FREQUENCY = { min: 20, max: 20000 };
export const EQ_GAIN = { min: -24, max: 24 };
export const EQ_Q = { min: 0.1, max: 30 };
/** The shapes with a gain, and those a slope makes steeper. */
const GAINED: ReadonlySet<EqBandType> = new Set(['lowshelf', 'highshelf', 'peaking']);
const SLOPED: ReadonlySet<EqBandType> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
export const hasGain = (type: EqBandType): boolean => GAINED.has(type);
export const hasSlope = (type: EqBandType): boolean => SLOPED.has(type);
/** Web Audio's shelves have no Q: theirs is fixed. */
export const hasQ = (type: EqBandType): boolean => type !== 'lowshelf' && type !== 'highshelf';
const FLAT_Q = Math.SQRT1_2;
const clampTo = (value: unknown, range: { min: number; max: number }, fallback: number): number => {
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
return Math.max(range.min, Math.min(range.max, n));
};
/** A band as a hand-edited project may hold it, held to what the editor offers. */
function bandOf(value: PropertyValue): EqBand | null {
if (typeof value !== 'object' || isList(value)) return null;
const item: StructValue = value;
const type = EQ_BAND_TYPES.find((t) => t.value === item.type)?.value ?? 'peaking';
const slope = EQ_SLOPES.find((s) => s === Number(item.slope)) ?? 12;
return {
type,
frequency: clampTo(item.frequency, EQ_FREQUENCY, 1000),
gain: clampTo(item.gain, EQ_GAIN, 0),
q: clampTo(item.q, EQ_Q, FLAT_Q),
slope,
};
}
export function eqBandsOf(params: PropertyBag): EqBand[] {
const list = params[EQ_BANDS_PARAM];
if (!isList(list)) return [];
return list
.map(bandOf)
.filter((band): band is EqBand => band !== null)
.slice(0, MAX_EQ_BANDS);
}
export function eqBandsValue(bands: readonly EqBand[]): PropertyValue {
return bands.map((band) => ({ ...band }));
}
/** A new band where the editor was clicked: a peak there. */
export function newEqBand(frequency: number, gain: number): EqBand {
const at = clampTo(frequency, EQ_FREQUENCY, 1000);
return { type: 'peaking', frequency: at, gain: clampTo(gain, EQ_GAIN, 0), q: 1, slope: 12 };
}
/** A band with some of its values changed, held to what it may be. */
export function withBandSet(bands: readonly EqBand[], index: number, change: Partial<EqBand>): EqBand[] {
return bands.map((band, i) => (i === index ? (bandOf({ ...band, ...change }) ?? band) : band));
}
/** Whether a band changes the sound at all: a peak or a shelf at 0 dB does not. */
export const isEqBandHeard = (band: EqBand): boolean => !hasGain(band.type) || band.gain !== 0;
/** How many filters a band runs in a row: one, or more for a steeper slope. */
export const eqStagesOf = (band: EqBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
export const EQ_SAMPLE_RATE = 48000;
interface Biquad {
b0: number;
b1: number;
b2: number;
a0: number;
a1: number;
a2: number;
}
/** The coefficients of one filter as the Web Audio specification gives them. */
function biquad(type: EqBandType, frequency: number, q: number, gain: number, rate: number): Biquad {
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
const cos = Math.cos(w0);
const sin = Math.sin(w0);
const a = 10 ** (gain / 40);
// For a low- and high-pass Web Audio takes Q in dB; as a factor that is the same alpha.
const alpha = sin / (2 * q);
const alphaS = (sin / 2) * Math.SQRT2;
const root = 2 * Math.sqrt(a) * alphaS;
switch (type) {
case 'lowpass':
return {
b0: (1 - cos) / 2,
b1: 1 - cos,
b2: (1 - cos) / 2,
a0: 1 + alpha,
a1: -2 * cos,
a2: 1 - alpha,
};
case 'highpass':
return {
b0: (1 + cos) / 2,
b1: -(1 + cos),
b2: (1 + cos) / 2,
a0: 1 + alpha,
a1: -2 * cos,
a2: 1 - alpha,
};
case 'bandpass':
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
case 'notch':
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
case 'peaking':
return {
b0: 1 + alpha * a,
b1: -2 * cos,
b2: 1 - alpha * a,
a0: 1 + alpha / a,
a1: -2 * cos,
a2: 1 - alpha / a,
};
case 'lowshelf':
return {
b0: a * (a + 1 - (a - 1) * cos + root),
b1: 2 * a * (a - 1 - (a + 1) * cos),
b2: a * (a + 1 - (a - 1) * cos - root),
a0: a + 1 + (a - 1) * cos + root,
a1: -2 * (a - 1 + (a + 1) * cos),
a2: a + 1 + (a - 1) * cos - root,
};
case 'highshelf':
return {
b0: a * (a + 1 + (a - 1) * cos + root),
b1: -2 * a * (a - 1 + (a + 1) * cos),
b2: a * (a + 1 + (a - 1) * cos - root),
a0: a + 1 - (a - 1) * cos + root,
a1: 2 * (a - 1 - (a + 1) * cos),
a2: a + 1 - (a - 1) * cos - root,
};
}
}
/** The lowest the curve goes, so a cut's deep end stays a number. */
const FLOOR_DB = -300;
function biquadDb(filter: Biquad, frequency: number, rate: number): number {
const w = (2 * Math.PI * frequency) / rate;
const [c1, s1, c2, s2] = [Math.cos(w), Math.sin(w), Math.cos(2 * w), Math.sin(2 * w)];
const numRe = filter.b0 + filter.b1 * c1 + filter.b2 * c2;
const numIm = filter.b1 * s1 + filter.b2 * s2;
const denRe = filter.a0 + filter.a1 * c1 + filter.a2 * c2;
const denIm = filter.a1 * s1 + filter.a2 * s2;
const power = (numRe * numRe + numIm * numIm) / (denRe * denRe + denIm * denIm);
return Math.max(FLOOR_DB, 10 * Math.log10(power));
}
/**
* What one band does, in dB at any frequency – its filters in a row, only the last with its Q. The
* coefficients are worked out once, so a curve of many points asks this rather than `eqBandDb`.
*/
export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
const stages = eqStagesOf(band);
const gain = hasGain(band.type) ? band.gain : 0;
const last = biquad(band.type, band.frequency, band.q, gain, rate);
const flat = stages > 1 ? biquad(band.type, band.frequency, FLAT_Q, 0, rate) : null;
return (frequency) => {
let db = biquadDb(last, frequency, rate);
if (flat) db += (stages - 1) * biquadDb(flat, frequency, rate);
return Math.max(FLOOR_DB, db);
};
}
/** What one band does at a frequency, in dB. */
export function eqBandDb(band: EqBand, frequency: number, rate = EQ_SAMPLE_RATE): number {
return eqBandResponse(band, rate)(frequency);
}
/** What all bands do together at a frequency, in dB. */
export function eqCurveDb(bands: readonly EqBand[], frequency: number, rate = EQ_SAMPLE_RATE): number {
let db = 0;
for (const band of bands) if (isEqBandHeard(band)) db += eqBandDb(band, frequency, rate);
return Math.max(FLOOR_DB, db);
}
+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];
+110
View File
@@ -0,0 +1,110 @@
/**
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, and the curve
* the editor draws from them.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { compileSoundFx } from '../../src/core/soundfx/compile';
import { fxChainOf } from '../../src/core/soundfx/nodes';
import {
type EqBand,
eqBandDb,
eqBandsOf,
eqCurveDb,
MAX_EQ_BANDS,
withBandSet,
} from '../../src/core/soundfx/parametricEq';
import { fillShapes } from '../../src/editor/soundfx/eqView';
import { graphEditor, makeProject, roundTrip } from '../helpers';
const band = (change: Partial<EqBand>): EqBand => ({
type: 'peaking',
frequency: 1000,
gain: 0,
q: 1,
slope: 12,
...change,
});
function eqLine(bands: unknown) {
const f = makeProject();
const command = new CreateAssetCommand('soundfx', 'EQ');
f.stack.execute(command);
const fx = command.asset as SoundFxAsset;
const edit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const eq = edit.addNode('SoundFxParametricEq');
eq.params.bands = bands as never;
const output = edit.addNode('SoundFxOutput');
edit.connect(input, 'out', eq, 'in');
edit.connect(eq, 'out', output, 'in');
return { ...f, fx };
}
describe('the bands of a Parametric EQ', () => {
it('are held to what the editor offers, at most ten of them', () => {
const bands = eqBandsOf({
bands: [
{ type: 'lowpass', frequency: 99999, gain: 3, q: 0, slope: 24 },
{ type: 'allpass', frequency: 5, gain: -99, q: 'x', slope: 7 },
'not a band',
...Array.from({ length: 12 }, () => ({ type: 'peaking', frequency: 500 })),
],
});
expect(bands).toHaveLength(MAX_EQ_BANDS);
expect(bands[0]).toEqual({ type: 'lowpass', frequency: 20000, gain: 3, q: 0.1, slope: 24 });
expect(bands[1]).toEqual({ type: 'peaking', frequency: 20, gain: -24, q: Math.SQRT1_2, slope: 12 });
expect(eqBandsOf({})).toEqual([]);
expect(withBandSet([band({})], 0, { gain: 40 })[0]!.gain).toBe(24);
});
it('compile to one filter each, a steeper cut to more, and a flat peak to none', () => {
const f = eqLine([
band({ gain: 6 }),
band({ type: 'highpass', frequency: 80, q: Math.SQRT1_2, slope: 24 }),
band({ type: 'lowshelf', frequency: 200, gain: 0 }),
]);
const build = compileSoundFx(f.fx.graph);
expect(build.effects).toBe(1);
expect(fxChainOf(build.compiled!).stages).toEqual([
{ kind: 'pass', filter: 'peaking', stages: 1, cutoff: 1000, q: 1, gain: 6 },
{ kind: 'pass', filter: 'highpass', stages: 2, cutoff: 80, q: 20 * Math.log10(Math.SQRT1_2), gain: 0 },
]);
expect(roundTrip(f.project)).toEqual(f.project);
});
it('draw the curve Web Audio plays', () => {
const peak = band({ gain: 6 });
expect(eqBandDb(peak, 1000)).toBeCloseTo(6, 3);
expect(Math.abs(eqBandDb(peak, 20))).toBeLessThan(0.1);
// A cut with no bump is 3 dB down at its frequency and falls 12 dB an octave below it.
const cut = band({ type: 'highpass', frequency: 200, q: Math.SQRT1_2 });
expect(eqBandDb(cut, 200)).toBeCloseTo(-3.01, 1);
expect(eqBandDb(cut, 50) - eqBandDb(cut, 25)).toBeCloseTo(12, 0);
const steeper = { ...cut, slope: 48 as const };
expect(eqBandDb(steeper, 50) - eqBandDb(steeper, 25)).toBeCloseTo(48, 0);
const shelf = band({ type: 'lowshelf', frequency: 200, gain: 6 });
expect(eqBandDb(shelf, 20)).toBeCloseTo(6, 1);
expect(eqBandDb(shelf, 200)).toBeCloseTo(3, 1);
expect(eqBandDb(band({ type: 'notch' }), 1000)).toBeLessThan(-60);
// Together they add up in dB.
const both = [peak, shelf];
expect(eqCurveDb(both, 300)).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 6);
});
it('fills a curve that crosses 0 dB with one shape for each side, meeting on the line', () => {
// From 10 above the line at y 50 to 10 below and back: it crosses twice, at x 15 and x 45.
const shapes = fillShapes([0, 10, 20, 30, 40, 50, 60], [40, 40, 60, 60, 60, 40, 40], 50);
expect(shapes).toEqual([
[0, 50, 0, 40, 10, 40, 15, 50],
[15, 50, 20, 60, 30, 60, 40, 60, 45, 50],
[45, 50, 50, 40, 60, 40, 60, 50],
]);
// On one side all along it stays one shape.
expect(fillShapes([0, 10, 20], [40, 30, 40], 50)).toEqual([[0, 50, 0, 40, 10, 30, 20, 40, 20, 50]]);
});
});
+8 -4
View File
@@ -8,6 +8,7 @@ import { RenamePortCommand, SetPortDefaultCommand } from '../../src/core/command
import { callProblem, resolveDef, soundFxParamsMismatch } from '../../src/core/graph';
import { type DeclaredPort, portsCompatible } from '../../src/core/graph/Graph';
import { graphLookup } from '../../src/core/model/graphLookup';
import type { PropertyBag } from '../../src/core/model/Properties';
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { assetRefChoices } from '../../src/core/properties/assetRef';
@@ -63,7 +64,7 @@ function makeFx(f: ProjectFixture = makeProject()) {
}
/** Input, the given effects in a row, Output. */
function line(effects: { type: string; params?: Record<string, number | boolean | string> }[]) {
function line(effects: { type: string; params?: PropertyBag }[]) {
const f = makeFx();
const input = f.edit.addNode('SoundFxInput');
let tail = input;
@@ -126,6 +127,7 @@ describe('the sound FX asset', () => {
'SoundFxGain',
'SoundFxPitch',
'SoundFxEq',
'SoundFxParametricEq',
'SoundFxFilter',
'SoundFxMix',
'SoundFxDelay',
@@ -146,6 +148,7 @@ describe('the sound FX asset', () => {
'SoundFxGain',
'SoundFxPitch',
'SoundFxEq',
'SoundFxParametricEq',
'SoundFxFilter',
'SoundFxMix',
'SoundFxDelay',
@@ -596,6 +599,7 @@ describe('Bypass and Wet (#354)', () => {
SoundFxGain: 'wet',
SoundFxLimiter: 'wet',
SoundFxPan: 'wet',
SoundFxParametricEq: 'wet',
SoundFxPhaser: 'mix',
SoundFxPitch: 'wet',
SoundFxReverb: 'mix',
@@ -608,7 +612,7 @@ describe('Bypass and Wet (#354)', () => {
const node = f.fx.graph.nodes.find((n) => n.type === type)!;
return resolveDef(node, f.fx.graph, graphLookup(f.project))!.inputs;
};
const stagesOf = (type: string, params: Record<string, number | boolean | string> = {}) =>
const stagesOf = (type: string, params: PropertyBag = {}) =>
compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages;
it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => {
@@ -625,12 +629,12 @@ describe('Bypass and Wet (#354)', () => {
def.type === 'SoundFxMix',
]);
}
expect(Object.keys(EFFECTS)).toHaveLength(15);
expect(Object.keys(EFFECTS)).toHaveLength(16);
});
it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => {
for (const type of Object.keys(EFFECTS)) {
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50 };
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50, bands: [{ gain: 6 }] };
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
expect([
type,
+65
View File
@@ -520,6 +520,71 @@ export async function sound(t) {
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the
// curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the
// voice passes one filter for it.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e10', 'fx-gain', 'out', 'fx-eq', 'in'))},
${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))},
);`);
const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')");
await t.doubleClick(eqNode.x, eqNode.y);
const eqShown = await json('__gm.eq.shown()');
const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)');
await t.doubleClick(eqPeakAt.x, eqPeakAt.y);
const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }');
const bandNear = (band, hz, db) =>
band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5;
check(
'a double-click opens the Parametric EQ’s editor, and one on its curve adds a peak there',
eqShown?.nodeId === 'fx-eq' &&
eqAdded.bands.length === 1 &&
eqAdded.bands[0].type === 'peaking' &&
bandNear(eqAdded.bands[0], 1000, 6) &&
eqAdded.fields.type === 'Peaking' &&
eqAdded.fields.slope === null,
JSON.stringify({ eqShown, eqAdded }),
);
const eqHandle = await json('__gm.eq.handleAt(0)');
const eqTo = await json('__gm.eq.pointAt(4000, -6)');
await drag(eqHandle.x, eqHandle.y, eqTo.x, eqTo.y);
const eqDragged = (await json('__gm.eq.bands()'))[0];
await press('z', { ctrl: true });
const eqUndone = (await json('__gm.eq.bands()'))[0];
await press('z', { ctrl: true, shift: true });
const eqHandleBack = await json('__gm.eq.handleAt(0)');
await t.wheel(eqHandleBack.x, eqHandleBack.y, -100);
const eqWheeled = (await json('__gm.eq.bands()'))[0];
check(
'dragging the band moves it as one undo step, and the wheel narrows it',
bandNear(eqDragged, 4000, -6) &&
bandNear(eqUndone, 1000, 6) &&
bandNear(eqWheeled, 4000, -6) &&
eqWheeled.q > 1,
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
);
await shot('34-parametric-eq');
await evaluate('__gm.sound.stopAll()');
const eqBefore = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started);
await evaluate('__gm.sound.stopAll()');
const eqClose = await json('__gm.eq.closeAt()');
await click(eqClose.x, eqClose.y);
check(
'the voice passes the gain and the band, and the editor closes by its button',
eqHeard.last?.stages === 2 && (await json('__gm.eq.shown()')) === null,
JSON.stringify(eqHeard.last),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-eq');
graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;