A Filter sounds like one band of the Parametric EQ, but it keeps what the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a node of its own and gets the EQ's editor with itself as the one band. The editors also stop running out of their room: what does not fit is left out instead of being cut. - The curve of the EQ is now BandCurve with a host the EQ and the Filter both fill. CurvePanel holds what their panels share. - FilterPanel shows the Filter as one band. A drag of its handle sets frequency and gain as one undo step. A number fed by a link is greyed out and the drag leaves it alone. - A double-click on the Filter's title opens the same in a tab of its own. Delete there does nothing, the node stays. - The Filter is tapped like the EQ, so its node and its tab draw the spectrum of what passes it. A tap is two gain nodes per voice, the analysers are made only while someone listens. - EqFace and FilterFace are one CurveFace now, given the panel. - EQ and Filter nodes have a corner to resize them, drawn like the floating window's. The size is saved on the node as `size`, each drag is one undo step, the least is 240 x 120. - In the node the help line is gone. The row of numbers comes in only once the curve keeps 120 px of height. A group of the row that does not fit is not drawn, the one on the right first. - A frequency or dB label that would run into the one before it is left out. - The all-pass is drawn flat. The curve's maths takes every shape a Filter has. Found on the way: - The instrument presets set nodes 240 apart. A Filter with its editor is about 700 wide and covered the Bitcrusher, so a Filter now takes four columns. Tests: - Unit: 8 new (filter band, resize command and save, fitting rules), stage lists with a Filter now hold its taps - e2e 34 (atx): ALL PASSED, 130 checks (4 new) - Gate (atx): CHECK EXIT 0, 284 files, 4367 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
/**
|
||
* The bands of a Parametric EQ (#367): up to `MAX_EQ_BANDS` filters in a row, kept as a list on the
|
||
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
||
* editor draws what is heard.
|
||
*/
|
||
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
||
import type { FxPassFilter } from './nodes';
|
||
|
||
export const EQ_BANDS_PARAM = 'bands';
|
||
export const MAX_EQ_BANDS = 10;
|
||
|
||
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
|
||
export type EqSlope = 12 | 24 | 48;
|
||
|
||
/** One filter as the curve draws it: a band of the EQ, or a Filter node (#385), all-pass and all. */
|
||
export interface PassBand {
|
||
type: FxPassFilter;
|
||
frequency: number;
|
||
gain: number;
|
||
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
|
||
q: number;
|
||
slope: EqSlope;
|
||
}
|
||
|
||
export interface EqBand extends PassBand {
|
||
type: EqBandType;
|
||
}
|
||
|
||
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
|
||
{ value: 'peaking', label: 'Peaking' },
|
||
{ value: 'lowshelf', label: 'Low shelf' },
|
||
{ value: 'highshelf', label: 'High shelf' },
|
||
{ value: 'highpass', label: 'Low cut' },
|
||
{ value: 'lowpass', label: 'High cut' },
|
||
{ value: 'bandpass', label: 'Band-pass' },
|
||
{ value: 'notch', label: 'Notch' },
|
||
];
|
||
export const EQ_SLOPES: readonly EqSlope[] = [12, 24, 48];
|
||
|
||
export const EQ_FREQUENCY = { min: 20, max: 20000 };
|
||
export const EQ_GAIN = { min: -24, max: 24 };
|
||
export const EQ_Q = { min: 0.1, max: 30 };
|
||
|
||
/** The shapes with a gain, and those a slope makes steeper. */
|
||
const GAINED: ReadonlySet<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
||
const SLOPED: ReadonlySet<FxPassFilter> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
||
|
||
export const hasGain = (type: FxPassFilter): boolean => GAINED.has(type);
|
||
export const hasSlope = (type: FxPassFilter): boolean => SLOPED.has(type);
|
||
/** Web Audio's shelves have no Q: theirs is fixed. */
|
||
export const hasQ = (type: FxPassFilter): boolean => type !== 'lowshelf' && type !== 'highshelf';
|
||
|
||
const FLAT_Q = Math.SQRT1_2;
|
||
|
||
const clampTo = (value: unknown, range: { min: number; max: number }, fallback: number): number => {
|
||
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||
return Math.max(range.min, Math.min(range.max, n));
|
||
};
|
||
|
||
/** A band as a hand-edited project may hold it, held to what the editor offers. */
|
||
function bandOf(value: PropertyValue): EqBand | null {
|
||
if (typeof value !== 'object' || isList(value)) return null;
|
||
const item: StructValue = value;
|
||
const type = EQ_BAND_TYPES.find((t) => t.value === item.type)?.value ?? 'peaking';
|
||
const slope = EQ_SLOPES.find((s) => s === Number(item.slope)) ?? 12;
|
||
return {
|
||
type,
|
||
frequency: clampTo(item.frequency, EQ_FREQUENCY, 1000),
|
||
gain: clampTo(item.gain, EQ_GAIN, 0),
|
||
q: clampTo(item.q, EQ_Q, FLAT_Q),
|
||
slope,
|
||
};
|
||
}
|
||
|
||
export function eqBandsOf(params: PropertyBag): EqBand[] {
|
||
const list = params[EQ_BANDS_PARAM];
|
||
if (!isList(list)) return [];
|
||
return list
|
||
.map(bandOf)
|
||
.filter((band): band is EqBand => band !== null)
|
||
.slice(0, MAX_EQ_BANDS);
|
||
}
|
||
|
||
export function eqBandsValue(bands: readonly EqBand[]): PropertyValue {
|
||
return bands.map((band) => ({ ...band }));
|
||
}
|
||
|
||
/** A new band where the editor was clicked: a peak there. */
|
||
export function newEqBand(frequency: number, gain: number): EqBand {
|
||
const at = clampTo(frequency, EQ_FREQUENCY, 1000);
|
||
return { type: 'peaking', frequency: at, gain: clampTo(gain, EQ_GAIN, 0), q: 1, slope: 12 };
|
||
}
|
||
|
||
/** A band with some of its values changed, held to what it may be. */
|
||
export function withBandSet(bands: readonly EqBand[], index: number, change: Partial<EqBand>): EqBand[] {
|
||
return bands.map((band, i) => (i === index ? (bandOf({ ...band, ...change }) ?? band) : band));
|
||
}
|
||
|
||
/** Whether a band changes the sound at all: a peak or a shelf at 0 dB does not. */
|
||
export const isEqBandHeard = (band: PassBand): boolean => !hasGain(band.type) || band.gain !== 0;
|
||
|
||
/** How many filters a band runs in a row: one, or more for a steeper slope. */
|
||
export const eqStagesOf = (band: PassBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
|
||
|
||
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
|
||
const EQ_SAMPLE_RATE = 48000;
|
||
|
||
interface Biquad {
|
||
b0: number;
|
||
b1: number;
|
||
b2: number;
|
||
a0: number;
|
||
a1: number;
|
||
a2: number;
|
||
}
|
||
|
||
/** The coefficients of one filter as the Web Audio specification gives them. */
|
||
function biquad(type: FxPassFilter, frequency: number, q: number, gain: number, rate: number): Biquad {
|
||
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
|
||
const cos = Math.cos(w0);
|
||
const sin = Math.sin(w0);
|
||
const a = 10 ** (gain / 40);
|
||
// For a low- and high-pass Web Audio takes Q in dB; as a factor that is the same alpha.
|
||
const alpha = sin / (2 * q);
|
||
const alphaS = (sin / 2) * Math.SQRT2;
|
||
const root = 2 * Math.sqrt(a) * alphaS;
|
||
switch (type) {
|
||
case 'lowpass':
|
||
return {
|
||
b0: (1 - cos) / 2,
|
||
b1: 1 - cos,
|
||
b2: (1 - cos) / 2,
|
||
a0: 1 + alpha,
|
||
a1: -2 * cos,
|
||
a2: 1 - alpha,
|
||
};
|
||
case 'highpass':
|
||
return {
|
||
b0: (1 + cos) / 2,
|
||
b1: -(1 + cos),
|
||
b2: (1 + cos) / 2,
|
||
a0: 1 + alpha,
|
||
a1: -2 * cos,
|
||
a2: 1 - alpha,
|
||
};
|
||
case 'bandpass':
|
||
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||
case 'notch':
|
||
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||
case 'allpass':
|
||
return { b0: 1 - alpha, b1: -2 * cos, b2: 1 + alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||
case 'peaking':
|
||
return {
|
||
b0: 1 + alpha * a,
|
||
b1: -2 * cos,
|
||
b2: 1 - alpha * a,
|
||
a0: 1 + alpha / a,
|
||
a1: -2 * cos,
|
||
a2: 1 - alpha / a,
|
||
};
|
||
case 'lowshelf':
|
||
return {
|
||
b0: a * (a + 1 - (a - 1) * cos + root),
|
||
b1: 2 * a * (a - 1 - (a + 1) * cos),
|
||
b2: a * (a + 1 - (a - 1) * cos - root),
|
||
a0: a + 1 + (a - 1) * cos + root,
|
||
a1: -2 * (a - 1 + (a + 1) * cos),
|
||
a2: a + 1 + (a - 1) * cos - root,
|
||
};
|
||
case 'highshelf':
|
||
return {
|
||
b0: a * (a + 1 + (a - 1) * cos + root),
|
||
b1: -2 * a * (a - 1 + (a + 1) * cos),
|
||
b2: a * (a + 1 + (a - 1) * cos - root),
|
||
a0: a + 1 - (a - 1) * cos + root,
|
||
a1: 2 * (a - 1 - (a + 1) * cos),
|
||
a2: a + 1 - (a - 1) * cos - root,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** The lowest the curve goes, so a cut's deep end stays a number. */
|
||
const FLOOR_DB = -300;
|
||
|
||
function biquadDb(filter: Biquad, frequency: number, rate: number): number {
|
||
const w = (2 * Math.PI * frequency) / rate;
|
||
const [c1, s1, c2, s2] = [Math.cos(w), Math.sin(w), Math.cos(2 * w), Math.sin(2 * w)];
|
||
const numRe = filter.b0 + filter.b1 * c1 + filter.b2 * c2;
|
||
const numIm = filter.b1 * s1 + filter.b2 * s2;
|
||
const denRe = filter.a0 + filter.a1 * c1 + filter.a2 * c2;
|
||
const denIm = filter.a1 * s1 + filter.a2 * s2;
|
||
const power = (numRe * numRe + numIm * numIm) / (denRe * denRe + denIm * denIm);
|
||
return Math.max(FLOOR_DB, 10 * Math.log10(power));
|
||
}
|
||
|
||
/**
|
||
* What one band does, in dB at any frequency – its filters in a row, only the last with its Q. The
|
||
* coefficients are worked out once, for a curve of many points.
|
||
*/
|
||
export function eqBandResponse(band: PassBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
|
||
const stages = eqStagesOf(band);
|
||
const gain = hasGain(band.type) ? band.gain : 0;
|
||
const last = biquad(band.type, band.frequency, band.q, gain, rate);
|
||
const flat = stages > 1 ? biquad(band.type, band.frequency, FLAT_Q, 0, rate) : null;
|
||
return (frequency) => {
|
||
let db = biquadDb(last, frequency, rate);
|
||
if (flat) db += (stages - 1) * biquadDb(flat, frequency, rate);
|
||
return Math.max(FLOOR_DB, db);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What each band does at every one of `frequencies`, in dB, and what the bands that are heard do
|
||
* together: the lines the EQ tab draws. Each band's coefficients are worked out once.
|
||
*/
|
||
export function eqCurves(
|
||
bands: readonly PassBand[],
|
||
frequencies: readonly number[],
|
||
rate = EQ_SAMPLE_RATE,
|
||
): { bands: number[][]; sum: number[] } {
|
||
const sum = frequencies.map(() => 0);
|
||
const each = bands.map((band) => {
|
||
const dbs = frequencies.map(eqBandResponse(band, rate));
|
||
if (isEqBandHeard(band)) dbs.forEach((db, i) => (sum[i]! += db));
|
||
return dbs;
|
||
});
|
||
return { bands: each, sum };
|
||
}
|
||
|
||
/**
|
||
* A picture of the curve, in the EQ's tab and in its node: frequency to the right in octaves, from
|
||
* 20 Hz to 20 kHz, and dB up, `range` either side of 0.
|
||
*/
|
||
export interface EqArea {
|
||
w: number;
|
||
h: number;
|
||
range: number;
|
||
}
|
||
|
||
/**
|
||
* The zoom of the curve (#376): the dB it shows above and below 0, one of `EQ_RANGES`. It is saved
|
||
* on the node, so the tab and the node show the same.
|
||
*/
|
||
export const EQ_RANGE_PARAM = 'range';
|
||
export const EQ_RANGES: readonly number[] = [3, 6, 12, 24, 48];
|
||
const DEFAULT_RANGE = EQ_GAIN.max;
|
||
|
||
export const eqRangeOf = (params: PropertyBag): number =>
|
||
EQ_RANGES.find((range) => range === params[EQ_RANGE_PARAM]) ?? DEFAULT_RANGE;
|
||
|
||
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
|
||
|
||
export const frequencyToX = (frequency: number, area: EqArea): number =>
|
||
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
|
||
|
||
export const xToFrequency = (x: number, area: EqArea): number =>
|
||
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
|
||
|
||
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / area.range) * (area.h / 2);
|
||
|
||
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * area.range;
|
||
|
||
/**
|
||
* The curves of `eqCurves` laid onto an area through `steps` + 1 points, evenly in octaves. A point
|
||
* stays within `overflow` of the area, so a deep cut runs out of sight rather than down to -300 dB.
|
||
*/
|
||
export function eqCurvePoints(
|
||
bands: readonly PassBand[],
|
||
area: EqArea,
|
||
steps: number,
|
||
overflow = 0,
|
||
): { xs: number[]; bands: number[][]; sum: number[] } {
|
||
const xs = Array.from({ length: steps + 1 }, (_, i) => (i / steps) * area.w);
|
||
const yOf = (db: number) => Math.max(-overflow, Math.min(area.h + overflow, dbToY(db, area)));
|
||
const curves = eqCurves(
|
||
bands,
|
||
xs.map((x) => xToFrequency(x, area)),
|
||
);
|
||
return { xs, bands: curves.bands.map((dbs) => dbs.map(yOf)), sum: curves.sum.map(yOf) };
|
||
}
|
||
|
||
/**
|
||
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
|
||
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
|
||
* line, and its fill would come apart.
|
||
*/
|
||
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
|
||
const shapes: number[][] = [];
|
||
let shape = [xs[0]!, zero];
|
||
for (let i = 0; i < xs.length; i++) {
|
||
const after = ys[i]! - zero;
|
||
const before = i > 0 ? ys[i - 1]! - zero : 0;
|
||
if (before * after < 0) {
|
||
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
|
||
shapes.push([...shape, x, zero]);
|
||
shape = [x, zero];
|
||
}
|
||
shape.push(xs[i]!, ys[i]!);
|
||
}
|
||
shapes.push([...shape, xs.at(-1)!, zero]);
|
||
return shapes;
|
||
}
|