From 3377524958bf1e97b14a5773fec21b8cc3bcf2e4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 16:36:33 +0200 Subject: [PATCH] Zoom the Parametric EQ's dB with the wheel, saved on its node (#376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close moves of a few dB were hard to set and to see on a curve that always spans -24 to +24 dB. The wheel on free space now zooms the dB the curve shows, and the node's small curve shows the same range. - The range is one of ±3, ±6, ±12, ±24 and ±48 dB, always the same either side of 0. Up zooms in. - It is saved on the node as `range`, so it is back after loading. Like the game scale (#226) it is no undo step. EditorState gets `setNodeView` for such a setting of a node. - The lines across the curve follow the range: every 1, 2, 3, 6 or 12 dB. A handle whose gain lies beyond the range stays on the edge, in sight and in reach. - The wheel over a handle still turns the band's Q. It no longer does so anywhere while a band is picked: after opening, a band nearly always is, and the zoom would be out of reach. - `EqArea` carries the range, so the tab and the node map dB alike. The spectrum keeps its own scale. Tests: - Unit parametric-eq: 16 green, 5 new (range in compile and save, node picture, wheel steps, grid lines, handle on the edge) - Counter-check: the node's picture without the saved range turns the new picture test red - e2e 34 (melektaus-atx): ALL PASSED, 99 checks, 1 new - Gate (melektaus-atx): CHECK EXIT 0, 275 files, 4252 tests Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/soundfx/nodes.ts | 2 +- src/core/soundfx/parametricEq.ts | 27 ++++++++++---- src/editor/debug/graphHooks.ts | 2 ++ src/editor/soundfx/EqEditor.ts | 61 ++++++++++++++++++++++---------- src/editor/soundfx/eqView.ts | 22 ++++++++++-- src/editor/state.ts | 15 ++++++++ tests/core/parametric-eq.test.ts | 58 +++++++++++++++++++++++++++--- tools/e2e/34-sound.mjs | 26 ++++++++++++++ 9 files changed, 181 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index e84a30fa..3565b418 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1921 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1922 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 73266515..d5e2da75 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -630,7 +630,7 @@ 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, here in small. 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. While the editor is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`, + description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve, here in small. 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, and anywhere else to zoom the dB the curve shows, here as well. 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. While the editor is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`, inputs: [audioIn], outputs: [audioOut], params: [], diff --git a/src/core/soundfx/parametricEq.ts b/src/core/soundfx/parametricEq.ts index 34f3cbfd..7ab382dc 100644 --- a/src/core/soundfx/parametricEq.ts +++ b/src/core/soundfx/parametricEq.ts @@ -223,15 +223,24 @@ export function eqCurves( /** * A picture of the curve, in the EQ's tab and in its node: frequency to the right in octaves, from - * 20 Hz to 20 kHz, and dB up, the same distance either side of 0. + * 20 Hz to 20 kHz, and dB up, `range` either side of 0. */ export interface EqArea { w: number; h: number; + range: number; } -/** The dB a picture of the curve shows above and below 0. */ -const EQ_VIEW_DB = EQ_GAIN.max; +/** + * The zoom of the curve (#376): the dB it shows above and below 0, one of `EQ_RANGES`. It is saved + * on the node, so the tab and the node show the same. + */ +export const EQ_RANGE_PARAM = 'range'; +export const EQ_RANGES: readonly number[] = [3, 6, 12, 24, 48]; +const DEFAULT_RANGE = EQ_GAIN.max; + +export const eqRangeOf = (params: PropertyBag): number => + EQ_RANGES.find((range) => range === params[EQ_RANGE_PARAM]) ?? DEFAULT_RANGE; const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min); @@ -241,9 +250,9 @@ export const frequencyToX = (frequency: number, area: EqArea): number => 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 dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / area.range) * (area.h / 2); -export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB; +export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * area.range; /** * The curves of `eqCurves` laid onto an area through `steps` + 1 points, evenly in octaves. A point @@ -301,8 +310,12 @@ function eqPictureShapes(bands: readonly EqBand[], area: EqArea): PictureShape[] ]; } -/** Without a band the box stays, with its 0 dB line: the node does not jump at the first one. */ +/** + * Without a band the box stays, with its 0 dB line: the node does not jump at the first one. It shows + * the dB the tab is zoomed to. + */ export const EQ_PICTURE: NodePicture = { height: PICTURE_HEIGHT, - shapes: (params, w) => eqPictureShapes(eqBandsOf(params), { w, h: PICTURE_HEIGHT }), + shapes: (params, w) => + eqPictureShapes(eqBandsOf(params), { w, h: PICTURE_HEIGHT, range: eqRangeOf(params) }), }; diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 55dd8e1f..46e587a1 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -124,6 +124,8 @@ export function graphHooks({ state, editor }: HookContext) { shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null), bands: () => editor.eqEditor.bands(), picked: () => editor.eqEditor.picked, + /** The dB the curve is zoomed to, above and below 0 (#376). */ + range: () => editor.eqEditor.range(), handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index), pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db), fields: () => { diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts index 111cd765..09dab8a7 100644 --- a/src/editor/soundfx/EqEditor.ts +++ b/src/editor/soundfx/EqEditor.ts @@ -2,8 +2,8 @@ * 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. While the tab is seen, the spectrum of what passes the node is drawn behind the curve - * (#374). + * undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376). While + * the tab is seen, the spectrum of what passes the node is drawn behind the curve (#374). */ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js'; import { SetNodeParamCommand } from '../../core/commands/graphCommands'; @@ -19,10 +19,13 @@ import { EQ_FREQUENCY, EQ_GAIN, EQ_Q, + EQ_RANGE_PARAM, + EQ_RANGES, EQ_SLOPES, eqBandsOf, eqBandsValue, eqCurvePoints, + eqRangeOf, fillShapes, frequencyToX, hasGain, @@ -56,13 +59,14 @@ import type { EditorState } from '../state'; import { TabHeader } from '../TabHeader'; import { bandAt, - EQ_GRID_DB, EQ_GRID_FREQUENCIES, + eqGridDb, handleOf, SPECTRUM_FLOOR_DB, spectrumPeak, spectrumYs, wheeledQ, + wheeledRange, } from './eqView'; /** The least the curve shrinks to; otherwise it takes the tab. */ @@ -83,6 +87,8 @@ const SPECTRUM_STEP = 2; /** What the curve asks of the tab around it. */ interface EqHost { bands(): EqBand[]; + range(): number; + zoom(range: number): void; picked: number; pick(index: number): void; preview(bands: EqBand[]): void; @@ -98,6 +104,8 @@ class EqCurve extends Widget { private readonly lines = new Graphics(); private readonly handles = new Graphics(); private readonly labels: Text[] = []; + /** As many as the most lines a range has; those of a range with fewer are hidden. */ + private readonly dbLabels: Text[] = []; private readonly numbers: Text[] = []; private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null; /** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */ @@ -116,11 +124,11 @@ class EqCurve extends Widget { 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 })); + const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length)); + for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted })); for (let i = 0; i < MAX_EQ_BANDS; i++) this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle })); - for (const text of [...this.labels, ...this.numbers]) { + for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) { text.eventMode = 'none'; this.addChild(text); } @@ -131,7 +139,7 @@ class EqCurve extends Widget { } get area(): EqArea { - return { w: this.w, h: this.h }; + return { w: this.w, h: this.h, range: this.host.range() }; } /** Where a band's handle is on screen – the check scripts aim with it. */ @@ -207,15 +215,19 @@ class EqCurve extends Widget { this.host.commit(this.host.bands()); } - /** The wheel over a band, or anywhere while one is picked, turns its Q. */ + /** The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). */ private wheel(e: FederatedWheelEvent): void { + if (e.deltaY === 0) return; + e.stopPropagation(); 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 index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); const band = bands[index]; - if (!band || !hasQ(band.type) || e.deltaY === 0) return; - e.stopPropagation(); + if (!band) { + this.host.zoom(wheeledRange(this.host.range(), e.deltaY)); + return; + } + if (!hasQ(band.type)) return; this.host.pick(index); this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) })); } @@ -233,12 +245,16 @@ class EqCurve extends Widget { 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 dbs = eqGridDb(area.range); + this.dbLabels.forEach((text, i) => { + const db = dbs[i]; + text.visible = db !== undefined; + if (db === undefined) return; const y = Math.round(dbToY(db, area)) + 0.5; this.grid.moveTo(0, y).lineTo(this.w, y); - const text = this.labels[label++]!; + text.text = db > 0 ? `+${db}` : `${db}`; text.position.set(3, y - text.height - 1); - } + }); this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 }); const zero = Math.round(dbToY(0, area)) + 0.5; this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 }); @@ -400,9 +416,10 @@ export class EqEditor extends VStack implements EqHost { 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', - }), + new Label( + 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.', + { style: 'muted' }, + ), ); this.add(this.header); this.add(this.body, 1); @@ -464,6 +481,14 @@ export class EqEditor extends VStack implements EqHost { return node ? eqBandsOf(node.params) : []; } + range(): number { + return eqRangeOf(this.node()?.params ?? {}); + } + + zoom(range: number): void { + this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range); + } + pick(index: number): void { if (this.picked === index) return; this.picked = index; diff --git a/src/editor/soundfx/eqView.ts b/src/editor/soundfx/eqView.ts index ce09f3ac..c9f5d1c2 100644 --- a/src/editor/soundfx/eqView.ts +++ b/src/editor/soundfx/eqView.ts @@ -7,14 +7,19 @@ import { type EqBand, dbToY, EQ_Q, + EQ_RANGES, frequencyToX, hasGain, xToFrequency, } from '../../core/soundfx/parametricEq'; -/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */ +/** + * Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. + * A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach. + */ export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } { - return { x: frequencyToX(band.frequency, area), y: dbToY(hasGain(band.type) ? band.gain : 0, area) }; + const y = dbToY(hasGain(band.type) ? band.gain : 0, area); + return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) }; } /** The band whose handle is under a point, the nearest one if several are; -1 for none. */ @@ -38,6 +43,12 @@ export function wheeledQ(q: number, deltaY: number): number { return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000)); } +/** The zoom after a turn of the wheel (#376): up shows the next smaller range, down the next larger. */ +export function wheeledRange(range: number, deltaY: number): number { + const at = EQ_RANGES.indexOf(range) + Math.sign(deltaY); + return EQ_RANGES[Math.max(0, Math.min(EQ_RANGES.length - 1, at))]!; +} + /** 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' }, @@ -51,7 +62,12 @@ export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [ { hz: 10000, label: '10k' }, { hz: 20000, label: '20k' }, ]; -export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18]; + +/** The dB lines for a range: in thirds of it up to ±6 dB, in quarters above, so whole numbers. */ +export function eqGridDb(range: number): number[] { + const parts = range <= 6 ? 3 : 4; + return Array.from({ length: parts * 2 - 1 }, (_, i) => ((i + 1 - parts) * range) / parts); +} /** The spectrum's own scale (#374): full scale at the top of the picture, this many dB at its foot. */ export const SPECTRUM_FLOOR_DB = -96; diff --git a/src/editor/state.ts b/src/editor/state.ts index 86d5d8cc..be093429 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -21,6 +21,7 @@ import { startScene, } from '../core/model/Project'; import { clipsOf } from '../core/model/sound'; +import type { PropertyValue } from '../core/model/Properties'; import { deserializeProject, serializeProject } from '../core/store/serialize'; import { requestRender } from '../ui'; import { consoleLog } from './console/log'; @@ -412,6 +413,20 @@ export class EditorState { this.emit('project'); } + /** + * A setting of a node that is only how it is looked at, like the zoom of the EQ's curve (#376): + * saved, and like the game scale no undo step. + */ + setNodeView(assetId: string, nodeId: string, key: string, value: PropertyValue): void { + const nodes = [this.project, this.edit?.project].map((project) => + project ? findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId) : undefined, + ); + if (!nodes[0] || nodes[0].params[key] === value) return; + for (const node of nodes) if (node) node.params[key] = value; + this.setSaveStatus('dirty'); + this.emit('project'); + } + /** * Runs a command through the undo stack. Returns false (and sets a notice) when invalid. * In play mode the command changes the runtime copy only (see `enterPlay`). diff --git a/tests/core/parametric-eq.test.ts b/tests/core/parametric-eq.test.ts index 0ca6fe7d..47cc3f6c 100644 --- a/tests/core/parametric-eq.test.ts +++ b/tests/core/parametric-eq.test.ts @@ -18,7 +18,14 @@ import { MAX_EQ_BANDS, withBandSet, } from '../../src/core/soundfx/parametricEq'; -import { SPECTRUM_FLOOR_DB, spectrumPeak, spectrumYs } from '../../src/editor/soundfx/eqView'; +import { + eqGridDb, + handleOf, + SPECTRUM_FLOOR_DB, + spectrumPeak, + spectrumYs, + wheeledRange, +} from '../../src/editor/soundfx/eqView'; import { graphEditor, makeProject, roundTrip } from '../helpers'; const band = (change: Partial): EqBand => ({ @@ -96,6 +103,15 @@ describe('the bands of a Parametric EQ', () => { expect(kinds()).toEqual([`in:${id}`, `out:${id}`]); }); + it('keep the zoom of their curve on the node, saved, and it changes nothing heard (#376)', () => { + const f = eqLine([band({ gain: 6 })]); + const stages = () => compileSoundFx(f.fx.graph, undefined, f.fx.id).compiled!.stages; + const before = stages(); + f.eq.params.range = 12; + expect(stages()).toEqual(before); + expect(roundTrip(f.project)).toEqual(f.project); + }); + it('draw the curve Web Audio plays', () => { const peak = band({ gain: 6 }); expect(bandDb(peak, 1000)).toBeCloseTo(6, 3); @@ -138,8 +154,10 @@ describe('the bands of a Parametric EQ', () => { describe('the curve in the node (#375)', () => { const picture = SoundFxParametricEq.picture!; const W = 164; - const area = { w: W, h: picture.height }; - const lineOf = (bands: unknown) => picture.shapes({ bands } as never, W).find((s) => s.kind === 'line')!; + const area = { w: W, h: picture.height, range: 24 }; + const lineOf = (bands: unknown, range?: number) => + picture.shapes({ bands, range } as never, W).find((s) => s.kind === 'line')!; + const topOf = (line: { points: number[] }) => Math.min(...line.points.filter((_, i) => i % 2 === 1)); it('has its highest point where the peak is, as high as its gain', () => { const line = lineOf([band({ gain: 6 })]); @@ -165,11 +183,43 @@ describe('the curve in the node (#375)', () => { expect(Math.max(...ys)).toBe(picture.height); expect(Math.min(...ys)).toBeGreaterThanOrEqual(0); }); + + it('shows the dB its tab is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => { + const peak = [band({ gain: 6 })]; + expect(topOf(lineOf(peak))).toBeCloseTo(dbToY(6, area), 1); + expect(topOf(lineOf(peak, 12))).toBeCloseTo(dbToY(6, { ...area, range: 12 }), 1); + expect(topOf(lineOf(peak, 12))).toBeCloseTo(picture.height / 4, 1); + expect(lineOf(peak, 7)).toEqual(lineOf(peak)); + }); +}); + +describe('the zoom of the curve in the tab (#376)', () => { + it('goes a range smaller with the wheel up and larger with it down, and stops at either end', () => { + expect(wheeledRange(24, -100)).toBe(12); + expect(wheeledRange(24, 100)).toBe(48); + expect(wheeledRange(3, -100)).toBe(3); + expect(wheeledRange(48, 100)).toBe(48); + }); + + it('rules the curve with whole dB, as many lines either side of 0', () => { + expect(eqGridDb(3)).toEqual([-2, -1, 0, 1, 2]); + expect(eqGridDb(6)).toEqual([-4, -2, 0, 2, 4]); + expect(eqGridDb(12)).toEqual([-9, -6, -3, 0, 3, 6, 9]); + expect(eqGridDb(24)).toEqual([-18, -12, -6, 0, 6, 12, 18]); + expect(eqGridDb(48)).toEqual([-36, -24, -12, 0, 12, 24, 36]); + }); + + it('keeps the handle of a gain beyond the range on the edge', () => { + const area = { w: 600, h: 300, range: 3 }; + expect(handleOf(band({ gain: 12 }), area).y).toBe(0); + expect(handleOf(band({ gain: -12 }), area).y).toBe(300); + expect(handleOf(band({ gain: 1.5 }), area).y).toBeCloseTo(75, 6); + }); }); describe('the spectrum behind the curve (#374)', () => { const BIN = 48000 / 8192; - const area = { w: 600, h: 300 }; + const area = { w: 600, h: 300, range: 24 }; const xs = Array.from({ length: 301 }, (_, i) => i * 2); /** All silence but one loud bin at about 1 kHz. */ const sine = () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 9d53c34b..299ca4ef 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -571,6 +571,32 @@ export async function sound(t) { eqWheeled.q > 1, JSON.stringify({ eqDragged, eqUndone, eqWheeled }), ); + // #376: the wheel on free space zooms the curve to ±12 dB, so the band's handle moves out with its + // gain. The zoom is saved on the node, the Q stays, and Ctrl+Z undoes the Q, not the zoom. + const eqFree = await json('__gm.eq.pointAt(50, 12)'); + await t.wheel(eqFree.x, eqFree.y, -100); + const eqZoomed = await json(`({ + range: __gm.eq.range(), + saved: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes + .find((n) => n.id === 'fx-eq').params.range, + q: __gm.eq.bands()[0].q, + handle: __gm.eq.handleAt(0), + at: __gm.eq.pointAt(4000, -6), + })`); + await press('z', { ctrl: true }); + const eqZoomUndone = await json('({ range: __gm.eq.range(), q: __gm.eq.bands()[0].q })'); + await press('z', { ctrl: true, shift: true }); + check( + 'the wheel on free space zooms the curve to ±12 dB, saved on the node, and Ctrl+Z leaves the zoom', + eqZoomed.range === 12 && + eqZoomed.saved === 12 && + eqZoomed.q === eqWheeled.q && + eqZoomed.handle.y > eqHandleBack.y + 10 && + Math.abs(eqZoomed.handle.y - eqZoomed.at.y) < 1 && + eqZoomUndone.range === 12 && + eqZoomUndone.q < eqWheeled.q, + JSON.stringify({ eqHandleBack, eqZoomed, eqZoomUndone }), + ); await shot('34-parametric-eq'); // #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node: // the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not