A sound FX or an instrument had a 5-Band-EQ with fixed bands and a Filter with one band. The Parametric EQ holds up to ten bands of any type, and a double-click on the node opens an editor that draws the curve they make together. - The bands are a list on the node (`bands`): type, frequency, gain, Q and slope. A hand-edited project is held to the editor's ranges and to ten bands. - Each band compiles to the existing `pass` stage, so the audio core is unchanged. A peak or a shelf at 0 dB is left out. - The curve is worked out in `parametricEq.ts` with the biquad formulas of the Web Audio spec, so the editor draws what is heard. - The editor is a window that floats over the editor without a backdrop, so the tab's Play stays usable. A double-click adds a peak, a drag moves a band, the wheel turns its Q, a right-click removes it. Fields below the curve set the picked band. It opens in the middle of the screen and moves by its title bar. - A drag or a scrub writes the node as a preview and makes one undo step on release. The panels hear of the preview once a frame, and the window lays itself out only when one of its texts changes. - The area under the curve is filled as one shape for each side of 0 dB. As a single shape it crossed itself and came apart. - The curve works out each band's coefficients once and adds the sum up from the bands. A drag took 295 ms of script time in a profile before and 64 ms after. Decided on my own: - Bands are added by a double-click, so a single click can let go of the picked band without adding one. - The cuts are labelled "Low cut" and "High cut" as in an EQ, not "High-pass" and "Low-pass" as on the Filter. - The bands have no input, so no parameter or Envelope moves them. Tests: - Unit parametric-eq (new) and soundfx: 69 green. Counter-probe with no stages emitted: the compile test red. Counter-probe with one fill shape: the fill test red. - e2e 34 (melektaus-atx): ALL PASSED, 96 checks (3 new). - Counter-probe e2e: no stages emitted, the playing check red. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.4 KiB
TypeScript
111 lines
4.4 KiB
TypeScript
/**
|
|
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, and the curve
|
|
* the editor draws from them.
|
|
*/
|
|
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 } from '../../src/core/soundfx/nodes';
|
|
import {
|
|
type EqBand,
|
|
eqBandDb,
|
|
eqBandsOf,
|
|
eqCurveDb,
|
|
MAX_EQ_BANDS,
|
|
withBandSet,
|
|
} from '../../src/core/soundfx/parametricEq';
|
|
import { fillShapes } 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,
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
expect(build.effects).toBe(1);
|
|
expect(fxChainOf(build.compiled!).stages).toEqual([
|
|
{ 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 },
|
|
]);
|
|
expect(roundTrip(f.project)).toEqual(f.project);
|
|
});
|
|
|
|
it('draw the curve Web Audio plays', () => {
|
|
const peak = band({ gain: 6 });
|
|
expect(eqBandDb(peak, 1000)).toBeCloseTo(6, 3);
|
|
expect(Math.abs(eqBandDb(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(eqBandDb(cut, 200)).toBeCloseTo(-3.01, 1);
|
|
expect(eqBandDb(cut, 50) - eqBandDb(cut, 25)).toBeCloseTo(12, 0);
|
|
const steeper = { ...cut, slope: 48 as const };
|
|
expect(eqBandDb(steeper, 50) - eqBandDb(steeper, 25)).toBeCloseTo(48, 0);
|
|
|
|
const shelf = band({ type: 'lowshelf', frequency: 200, gain: 6 });
|
|
expect(eqBandDb(shelf, 20)).toBeCloseTo(6, 1);
|
|
expect(eqBandDb(shelf, 200)).toBeCloseTo(3, 1);
|
|
expect(eqBandDb(band({ type: 'notch' }), 1000)).toBeLessThan(-60);
|
|
|
|
// Together they add up in dB.
|
|
const both = [peak, shelf];
|
|
expect(eqCurveDb(both, 300)).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 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]]);
|
|
});
|
|
});
|