Files
feuerwerk/tests/core/parametric-eq.test.ts
T
adminandClaude Opus 5.5 79a04e6764 Edit a Parametric EQ in its node, let the wheel only zoom graphs (#381)
The small curve below the EQ node's rows (#375) only showed the bands.
Like the Compressor since #380, the node now shows the editor of its
tab between its inputs and its output, and is worked right there. A
double-click on the node still opens the same editor in a tab.

Two wishes for the nodes with a panel came on top and share the same
files, so they are in this commit as well: the numbers below the
Compressor's knobs read as plain labels (#382), and the wheel over a
node zooms the graph instead of turning a value (#383).

#381, the EQ in its node:
- EqPanel holds the curve, the band's numbers and the help line, taken
  out of EqEditor. The tab puts its one panel under a header, each EQ
  node has a panel of its own at 720 x 360 (EqFace).
- A press on the curve neither picks nor moves the node. A
  double-click on the curve adds a band, one on the title or the edge
  opens the tab.
- In the node a press on the Type or Slope button stays with the
  button, so the node is not picked or moved.
- The spectrum runs in the node while it is in the picture. The tab
  lets go of the tap once, like the Compressor's, and Editor.tick lets
  every unseen tab and graph go before any seen one listens.
- The count of bands moved from the tab's header into the band row,
  so the node shows it as well.
- The node picture of #375 is gone with its only user: NodePicture,
  EQ_PICTURE and NodeView's drawing of it.

#382, the numbers below the Compressor's knobs in its node:
- A Knob with typed: false shows its number as a centred Label without
  a box, in the panel's ink. The ratio reads 4:1 (ratioText).
- The label is as high as its text and sits up in the dial's open
  bottom, right under the ends of the scale.
- Label takes a colour of its own.

#383, the wheel in a graph:
- GraphCanvas takes the wheel on the way down and stops it there. It
  zooms over a node too, and no knob or curve in a node sees it.
- The tabs keep their wheel: a knob's step, a band's Q and the zoom
  of the EQ's dB.

Found on the way:
- A Menu opened below a button in the zoomed graph would have been
  placed by the button's own height. openBelow now takes the bottom
  edge as drawn.
- The band row did not lay out its labels after a text change when
  its own size stayed. In the tab a later resize hid it, in the node
  "Band 1" and the count were missing. The row now lays out itself.
- Handle and point positions for the checks were unscaled. They now
  go through toGlobal and hold at the graph's zoom.

Tests:
- Unit parametric-eq: 16 tests. The #375 tests now check
  eqCurvePoints and eqRangeOf directly. compressor-view: ratioText.
- e2e 34 (atx): ALL PASSED, 125 checks (3 new: the EQ panel and its
  ports, editing in the node, the spectrum in the node with the tab
  open). The Compressor node's check reads its numbers as labels and
  8:1. Both node checks now expect the wheel to zoom the graph and
  leave the ratio, the Q and the dB zoom alone.
- The unseen-tab check now leaves for the Scene tab, since the node
  in the graph listens to the same tap.
- Counter-check: the tab letting go of the tap every unseen frame
  stalls the node's spectrum with the tab open (1 frame against 22,
  probe on stein2).
- Gate (atx): CHECK EXIT 0, 283 files, 4357 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:40:11 +02:00

251 lines
10 KiB
TypeScript

/**
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, the curve the
* editor draws from them, and the taps the spectrum is heard at (#374).
*/
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, tapId } from '../../src/core/soundfx/nodes';
import {
dbToY,
type EqBand,
eqBandResponse,
eqBandsOf,
eqCurvePoints,
eqCurves,
eqRangeOf,
fillShapes,
frequencyToX,
MAX_EQ_BANDS,
withBandSet,
} from '../../src/core/soundfx/parametricEq';
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>): EqBand => ({
type: 'peaking',
frequency: 1000,
gain: 0,
q: 1,
slope: 12,
...change,
});
/** What one band does at a frequency, in dB, as the curves are worked out. */
const bandDb = (of: EqBand, frequency: number) => eqBandResponse(of)(frequency);
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, eq };
}
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, undefined, f.fx.id);
const id = tapId(f.fx.id, f.eq.id);
expect(build.effects).toBe(1);
expect(fxChainOf(build.compiled!).stages).toEqual([
{ kind: 'tap', id, at: 'in' },
{ 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 },
{ kind: 'tap', id, at: 'out' },
]);
expect(roundTrip(f.project)).toEqual(f.project);
});
it('are tapped in front of the node and behind it, behind its Wet and when bypassed too', () => {
const f = eqLine([band({ gain: 6 })]);
const id = tapId(f.fx.id, f.eq.id);
const kinds = () =>
compileSoundFx(f.fx.graph, undefined, f.fx.id).compiled!.stages.map((s) =>
s.kind === 'tap' ? `${s.at}:${s.id}` : s.kind,
);
f.eq.params.wet = 0.5;
expect(kinds()).toEqual([`in:${id}`, 'wet', `out:${id}`]);
f.eq.params.bypass = true;
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);
expect(Math.abs(bandDb(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(bandDb(cut, 200)).toBeCloseTo(-3.01, 1);
expect(bandDb(cut, 50) - bandDb(cut, 25)).toBeCloseTo(12, 0);
const steeper = { ...cut, slope: 48 as const };
expect(bandDb(steeper, 50) - bandDb(steeper, 25)).toBeCloseTo(48, 0);
const shelf = band({ type: 'lowshelf', frequency: 200, gain: 6 });
expect(bandDb(shelf, 20)).toBeCloseTo(6, 1);
expect(bandDb(shelf, 200)).toBeCloseTo(3, 1);
expect(bandDb(band({ type: 'notch' }), 1000)).toBeLessThan(-60);
// Together they add up in dB; a band that does nothing is drawn but not counted.
const flat = band({ type: 'peaking', frequency: 300, gain: 0 });
const curves = eqCurves([peak, flat, shelf], [300, 3000]);
expect(curves.bands[1]).toHaveLength(2);
expect(curves.bands[2]![0]).toBeCloseTo(bandDb(shelf, 300), 6);
expect(curves.sum[0]).toBeCloseTo(bandDb(peak, 300) + bandDb(shelf, 300), 6);
expect(curves.sum[1]).toBeCloseTo(bandDb(peak, 3000) + bandDb(shelf, 3000), 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]]);
});
});
describe('the curve as the panel draws it', () => {
const W = 164;
const H = 60;
const area = { w: W, h: H, range: 24 };
const topOf = (ys: number[]) => Math.min(...ys);
it('has its highest point where the peak is, as high as its gain', () => {
const { xs, sum } = eqCurvePoints([band({ gain: 6 })], area, 96);
const top = sum.indexOf(topOf(sum));
expect(Math.abs(xs[top]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
expect(sum[top]).toBeCloseTo(dbToY(6, area), 1);
expect(xs.length).toBe(97);
});
it('lies flat on the 0 dB line without a band', () => {
const { sum } = eqCurvePoints([], area, 96);
expect(sum.every((y) => y === dbToY(0, area))).toBe(true);
});
it('stays inside its box however deep a cut goes, or as far past it as it is let', () => {
const cut = [band({ type: 'highpass', frequency: 2000, slope: 48 })];
const { sum } = eqCurvePoints(cut, area, 96);
expect(Math.max(...sum)).toBe(H);
expect(Math.min(...sum)).toBeGreaterThanOrEqual(0);
expect(Math.max(...eqCurvePoints(cut, area, 96, 2).sum)).toBe(H + 2);
});
it('shows the dB the node is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => {
const peak = [band({ gain: 6 })];
const at = (params: object) =>
topOf(eqCurvePoints(peak, { ...area, range: eqRangeOf(params as never) }, 96).sum);
expect(at({})).toBeCloseTo(dbToY(6, area), 1);
expect(at({ range: 12 })).toBeCloseTo(H / 4, 1);
expect(at({ range: 7 })).toBe(at({}));
});
});
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, range: 24 };
const xs = Array.from({ length: 301 }, (_, i) => i * 2);
/** All silence but one loud bin at about 1 kHz. */
const sine = () => {
const dbs = new Float32Array(4096).fill(-Infinity);
dbs[Math.round(1000 / BIN)] = -12;
return dbs;
};
it('finds the loudest bin, and nothing in silence', () => {
expect(spectrumPeak(sine(), BIN)!.hz).toBeCloseTo(Math.round(1000 / BIN) * BIN, 6);
expect(spectrumPeak(sine(), BIN)!.db).toBe(-12);
expect(spectrumPeak(new Float32Array(4096).fill(-Infinity), BIN)).toBeNull();
});
it('draws the loud bin where 1 kHz is, at its level, and silence on the floor', () => {
const ys = spectrumYs(sine(), BIN, xs, area);
const top = ys.indexOf(Math.min(...ys));
expect(Math.abs(xs[top]! - frequencyToX(1000, area))).toBeLessThanOrEqual(2);
expect(ys[top]).toBeCloseTo((-12 / SPECTRUM_FLOOR_DB) * area.h, 6);
expect(ys.filter((y) => y === area.h).length).toBeGreaterThan(xs.length - 4);
});
it('down in the bass, where bins lie further apart than the points, goes between them', () => {
const dbs = new Float32Array(4096).fill(-60);
dbs[4] = -20;
const at = (hz: number) => spectrumYs(dbs, BIN, [frequencyToX(hz, area)], area)[0]!;
// Bin 4 is 23.4 Hz, bin 5 29.3 Hz: halfway the level is halfway.
expect(at(4 * BIN)).toBeCloseTo((-20 / SPECTRUM_FLOOR_DB) * area.h, 6);
expect(at(4.5 * BIN)).toBeCloseTo((-40 / SPECTRUM_FLOOR_DB) * area.h, 6);
});
});