Files
feuerwerk/src/core/soundfx/parametricEq.ts
T
adminandClaude Opus 5.5 c087993000 Edit a Filter in its node and resize EQ and Filter nodes (#385)
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>
2026-10-10 14:30:00 +02:00

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}