Refactoring round 11 covers what came since round 10 (25084f8): one commit,6873776, the curve in the EQ's node and the spectrum in its tab (#375, #374). Behaviour is unchanged. Duplicate code (jscpd 4.3.0 over src, 50 tokens, TypeScript only, as in round 10, measured on25084f8and here alike): 33 clones / 249 lines before and after. The two issues brought none. Dead code (TS language service, references per export and member): - eqBandDb was called by tests only. It goes, and the tests ask eqBandResponse, which works out the curves the tab and the node draw. - eqPictureShapes and SPECTRUM_SIZE were used only in their own file and lose their `export`. - Kept on purpose: the members of EqCurve are called by the tab class in the same file, and the processors' render, process and cut are the worklet's own, as in round 10. StageNodes is a type, and types stay exported. Comments and docs: no comment of the two issues tells history or runs past the line width. README and wiki were brought up to date in6873776, and every path they name exists. Tests: Bundle md5 identical before and after Gate (melektaus-atx) 275 files, 4247 tests, CHECK EXIT 0 Unit, touched areas 2 files, 14 tests No check was added or changed, the e2e sum stays at 1921. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
8.7 KiB
TypeScript
204 lines
8.7 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, the curve in the node (#375), 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, SoundFxParametricEq, tapId } from '../../src/core/soundfx/nodes';
|
|
import {
|
|
dbToY,
|
|
type EqBand,
|
|
eqBandResponse,
|
|
eqBandsOf,
|
|
eqCurves,
|
|
fillShapes,
|
|
frequencyToX,
|
|
MAX_EQ_BANDS,
|
|
withBandSet,
|
|
} from '../../src/core/soundfx/parametricEq';
|
|
import { SPECTRUM_FLOOR_DB, spectrumPeak, spectrumYs } 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('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 in the node (#375)', () => {
|
|
const picture = SoundFxParametricEq.picture!;
|
|
const W = 164;
|
|
const area = { w: W, h: picture.height };
|
|
const lineOf = (bands: unknown) => picture.shapes({ bands } as never, W).find((s) => s.kind === 'line')!;
|
|
|
|
it('has its highest point where the peak is, as high as its gain', () => {
|
|
const line = lineOf([band({ gain: 6 })]);
|
|
let top = 1;
|
|
for (let i = 3; i < line.points.length; i += 2) if (line.points[i]! < line.points[top]!) top = i;
|
|
expect(Math.abs(line.points[top - 1]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
|
|
expect(line.points[top]).toBeCloseTo(dbToY(6, area), 1);
|
|
expect(line.points.length).toBe(97 * 2);
|
|
});
|
|
|
|
it('keeps its box without a band, flat on the 0 dB line', () => {
|
|
const shapes = picture.shapes({}, W);
|
|
const zero = dbToY(0, area);
|
|
expect(picture.height).toBe(60);
|
|
expect(shapes[0]).toEqual({ kind: 'guide', points: [0, zero, W, zero] });
|
|
const line = shapes.find((s) => s.kind === 'line')!;
|
|
expect(line.points.filter((_, i) => i % 2 === 1).every((y) => y === zero)).toBe(true);
|
|
});
|
|
|
|
it('stays inside its box however deep a cut goes', () => {
|
|
const line = lineOf([band({ type: 'highpass', frequency: 2000, slope: 48 })]);
|
|
const ys = line.points.filter((_, i) => i % 2 === 1);
|
|
expect(Math.max(...ys)).toBe(picture.height);
|
|
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('the spectrum behind the curve (#374)', () => {
|
|
const BIN = 48000 / 8192;
|
|
const area = { w: 600, h: 300 };
|
|
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);
|
|
});
|
|
});
|