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

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+1 -1
View File
@@ -630,7 +630,7 @@ export const SoundFxParametricEq: SoundFxNodeDef = effect(
type: 'SoundFxParametricEq', type: 'SoundFxParametricEq',
label: 'Parametric EQ', label: 'Parametric EQ',
category: 'sound', 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], inputs: [audioIn],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
+20 -7
View File
@@ -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 * 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 { export interface EqArea {
w: number; w: number;
h: 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); 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 => export const xToFrequency = (x: number, area: EqArea): number =>
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN); 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 * 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 = { export const EQ_PICTURE: NodePicture = {
height: PICTURE_HEIGHT, 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) }),
}; };
+2
View File
@@ -124,6 +124,8 @@ export function graphHooks({ state, editor }: HookContext) {
shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null), shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null),
bands: () => editor.eqEditor.bands(), bands: () => editor.eqEditor.bands(),
picked: () => editor.eqEditor.picked, 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), handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db), pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
fields: () => { 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 * 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 * 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 * 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 * undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376). While
* (#374). * 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 { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import { SetNodeParamCommand } from '../../core/commands/graphCommands';
@@ -19,10 +19,13 @@ import {
EQ_FREQUENCY, EQ_FREQUENCY,
EQ_GAIN, EQ_GAIN,
EQ_Q, EQ_Q,
EQ_RANGE_PARAM,
EQ_RANGES,
EQ_SLOPES, EQ_SLOPES,
eqBandsOf, eqBandsOf,
eqBandsValue, eqBandsValue,
eqCurvePoints, eqCurvePoints,
eqRangeOf,
fillShapes, fillShapes,
frequencyToX, frequencyToX,
hasGain, hasGain,
@@ -56,13 +59,14 @@ import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader'; import { TabHeader } from '../TabHeader';
import { import {
bandAt, bandAt,
EQ_GRID_DB,
EQ_GRID_FREQUENCIES, EQ_GRID_FREQUENCIES,
eqGridDb,
handleOf, handleOf,
SPECTRUM_FLOOR_DB, SPECTRUM_FLOOR_DB,
spectrumPeak, spectrumPeak,
spectrumYs, spectrumYs,
wheeledQ, wheeledQ,
wheeledRange,
} from './eqView'; } from './eqView';
/** The least the curve shrinks to; otherwise it takes the tab. */ /** 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. */ /** What the curve asks of the tab around it. */
interface EqHost { interface EqHost {
bands(): EqBand[]; bands(): EqBand[];
range(): number;
zoom(range: number): void;
picked: number; picked: number;
pick(index: number): void; pick(index: number): void;
preview(bands: EqBand[]): void; preview(bands: EqBand[]): void;
@@ -98,6 +104,8 @@ class EqCurve extends Widget {
private readonly lines = new Graphics(); private readonly lines = new Graphics();
private readonly handles = new Graphics(); private readonly handles = new Graphics();
private readonly labels: Text[] = []; 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 readonly numbers: Text[] = [];
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null; 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. */ /** 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(); const onHandle = textStyle('small').clone();
onHandle.fill = Theme.colors.symbolOutline; onHandle.fill = Theme.colors.symbolOutline;
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted })); for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
for (const db of EQ_GRID_DB) const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length));
this.labels.push(crispText({ text: db > 0 ? `+${db}` : `${db}`, style: muted })); for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted }));
for (let i = 0; i < MAX_EQ_BANDS; i++) for (let i = 0; i < MAX_EQ_BANDS; i++)
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle })); 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'; text.eventMode = 'none';
this.addChild(text); this.addChild(text);
} }
@@ -131,7 +139,7 @@ class EqCurve extends Widget {
} }
get area(): EqArea { 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. */ /** 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()); 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 { private wheel(e: FederatedWheelEvent): void {
if (e.deltaY === 0) return;
e.stopPropagation();
const at = this.toLocal(e.global); const at = this.toLocal(e.global);
const bands = this.host.bands(); const bands = this.host.bands();
const under = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
const index = under >= 0 ? under : this.host.picked;
const band = bands[index]; const band = bands[index];
if (!band || !hasQ(band.type) || e.deltaY === 0) return; if (!band) {
e.stopPropagation(); this.host.zoom(wheeledRange(this.host.range(), e.deltaY));
return;
}
if (!hasQ(band.type)) return;
this.host.pick(index); this.host.pick(index);
this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) })); 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++]!; const text = this.labels[label++]!;
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2); 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; const y = Math.round(dbToY(db, area)) + 0.5;
this.grid.moveTo(0, y).lineTo(this.w, y); 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); text.position.set(3, y - text.height - 1);
} });
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 }); this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
const zero = Math.round(dbToY(0, area)) + 0.5; 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 }); 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(this.curve, 1);
this.body.add(row); this.body.add(row);
this.body.add( this.body.add(
new Label('Double-click: add a band. Drag: frequency and gain. Wheel: Q. Right-click: remove.', { new Label(
style: 'muted', '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.header);
this.add(this.body, 1); this.add(this.body, 1);
@@ -464,6 +481,14 @@ export class EqEditor extends VStack implements EqHost {
return node ? eqBandsOf(node.params) : []; 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 { pick(index: number): void {
if (this.picked === index) return; if (this.picked === index) return;
this.picked = index; this.picked = index;
+19 -3
View File
@@ -7,14 +7,19 @@ import {
type EqBand, type EqBand,
dbToY, dbToY,
EQ_Q, EQ_Q,
EQ_RANGES,
frequencyToX, frequencyToX,
hasGain, hasGain,
xToFrequency, xToFrequency,
} from '../../core/soundfx/parametricEq'; } 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 } { 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. */ /** 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)); 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. */ /** The lines the picture is ruled with, and what they say. */
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [ export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
{ hz: 20, label: '20' }, { hz: 20, label: '20' },
@@ -51,7 +62,12 @@ export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
{ hz: 10000, label: '10k' }, { hz: 10000, label: '10k' },
{ hz: 20000, label: '20k' }, { 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. */ /** 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; export const SPECTRUM_FLOOR_DB = -96;
+15
View File
@@ -21,6 +21,7 @@ import {
startScene, startScene,
} from '../core/model/Project'; } from '../core/model/Project';
import { clipsOf } from '../core/model/sound'; import { clipsOf } from '../core/model/sound';
import type { PropertyValue } from '../core/model/Properties';
import { deserializeProject, serializeProject } from '../core/store/serialize'; import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui'; import { requestRender } from '../ui';
import { consoleLog } from './console/log'; import { consoleLog } from './console/log';
@@ -412,6 +413,20 @@ export class EditorState {
this.emit('project'); 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. * 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`). * In play mode the command changes the runtime copy only (see `enterPlay`).
+54 -4
View File
@@ -18,7 +18,14 @@ import {
MAX_EQ_BANDS, MAX_EQ_BANDS,
withBandSet, withBandSet,
} from '../../src/core/soundfx/parametricEq'; } 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'; import { graphEditor, makeProject, roundTrip } from '../helpers';
const band = (change: Partial<EqBand>): EqBand => ({ const band = (change: Partial<EqBand>): EqBand => ({
@@ -96,6 +103,15 @@ describe('the bands of a Parametric EQ', () => {
expect(kinds()).toEqual([`in:${id}`, `out:${id}`]); 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', () => { it('draw the curve Web Audio plays', () => {
const peak = band({ gain: 6 }); const peak = band({ gain: 6 });
expect(bandDb(peak, 1000)).toBeCloseTo(6, 3); 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)', () => { describe('the curve in the node (#375)', () => {
const picture = SoundFxParametricEq.picture!; const picture = SoundFxParametricEq.picture!;
const W = 164; const W = 164;
const area = { w: W, h: picture.height }; const area = { w: W, h: picture.height, range: 24 };
const lineOf = (bands: unknown) => picture.shapes({ bands } as never, W).find((s) => s.kind === 'line')!; 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', () => { it('has its highest point where the peak is, as high as its gain', () => {
const line = lineOf([band({ gain: 6 })]); 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.max(...ys)).toBe(picture.height);
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0); 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)', () => { describe('the spectrum behind the curve (#374)', () => {
const BIN = 48000 / 8192; 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); const xs = Array.from({ length: 301 }, (_, i) => i * 2);
/** All silence but one loud bin at about 1 kHz. */ /** All silence but one loud bin at about 1 kHz. */
const sine = () => { const sine = () => {
+26
View File
@@ -571,6 +571,32 @@ export async function sound(t) {
eqWheeled.q > 1, eqWheeled.q > 1,
JSON.stringify({ eqDragged, eqUndone, eqWheeled }), 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'); await shot('34-parametric-eq');
// #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node: // #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 // the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not