Zoom the Parametric EQ's dB with the wheel, saved on its node (#376)
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:
1 parent
41af1a6e51
commit
3377524958
9 files changed
+181
-34
No files matched your search
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user