Zoom the Parametric EQ's dB with the wheel, saved on its node (#376)
game-maker / gate (push) Successful in 3m48s
game-maker / suite (push) Successful in 14m59s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 16:36:33 +02:00
1 parent 41af1a6e51
commit 3377524958
9 files changed
+181 -34

No files matched your search

+2
View File
@@ -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: () => {
+43 -18
View File
@@ -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;
+19 -3
View File
@@ -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;
+15
View File
@@ -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`).