Both were asked for in #367 "for later". The node of a Parametric EQ now shows its curve in small, and its tab shows what passes the node while it is seen. #375, the curve in the node: - NodeDef has an optional `picture`: a height, and shapes made from the settings as plain point lists (guide, area, line). It is general, the Parametric EQ is its first user. - NodeView draws it in a box below the rows, again only when the box or the settings change. A drag in the EQ tab changes them, so the node follows it live. - Without a band the box stays, flat on its 0 dB line, so the node does not grow at the first band. - The axes of the curve, `eqCurvePoints` and `fillShapes` move from editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node draw the sum from the same function. #374, the spectrum: - A node marked `tapped` gets a `tap` stage in front of its own stages and one behind them when compiled, in a sound FX and in an instrument alike. Its id is asset and node, since a copied asset keeps its node ids. - A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts) holds the tap of every voice and bus while it plays. - While the EQ tab is seen, in the tab row or in a window, the taps of that node feed one analyser per side (8192 points). Nothing is made before a tap of the node plays, so no AudioContext is opened early. - The tab draws both behind the curve, on a scale of 0 to -96 dBFS: the sound coming in faint and grey, the sound going on in the colour of a sound port. The analyser smooths it, and once the sound has gone the sum still hands it silence, so it falls away. - Once the tab is not seen, Editor.tick lets the analysers go, like the meters of the Song tab. - A tap does nothing to the sound, so `stats().last.stages` leaves it out, and a bypassed EQ keeps its taps. Decided on my own: - Every sound passing the node counts: Play of the sound FX tab, an instrument's keys, a song, a run. The issue proposed that. - The spectrum has no labels of its own. Its scale is in the wiki. Tests: Gate (melektaus-atx) 275 files, 4247 tests (11 new), EXIT 0 e2e 34 (melektaus-atx) ALL PASSED, 98 checks (2 new), twice Counter-checks, each put back by copy and compared with cmp: - The node's curve drawn without its bands: 2 unit tests fail. - The spectrum without the loudest bin between two points: 1 fails. - SpectrumTaps.stop leaving the taps linked: 1 fails. - Taps that never feed the analyser: e2e 34 fails in exactly the spectrum check (frames 0). Run in the container only. The screenshots of the second run were looked at: the blip as one peak behind the curve, and the dip at 4 kHz in the node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.6 KiB
TypeScript
201 lines
8.6 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,
|
|
eqBandDb,
|
|
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,
|
|
});
|
|
|
|
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(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; 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(eqBandDb(shelf, 300), 6);
|
|
expect(curves.sum[0]).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 6);
|
|
expect(curves.sum[1]).toBeCloseTo(eqBandDb(peak, 3000) + eqBandDb(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);
|
|
});
|
|
});
|