/** * 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 => ({ 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); }); });