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>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
/**
|
||
* The curve of a Parametric EQ (#367) and of a Filter (#385): what its bands do together, worked out
|
||
* as Web Audio plays them (`parametricEq.ts`). A band is dragged for frequency and gain and wheeled
|
||
* for its Q. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the
|
||
* tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what
|
||
* passes the node may be drawn behind the curve (#374).
|
||
*/
|
||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
|
||
import {
|
||
dbToY,
|
||
type EqArea,
|
||
EQ_RANGES,
|
||
eqCurvePoints,
|
||
fillShapes,
|
||
frequencyToX,
|
||
hasGain,
|
||
hasQ,
|
||
MAX_EQ_BANDS,
|
||
type PassBand,
|
||
xToFrequency,
|
||
yToDb,
|
||
} from '../../core/soundfx/parametricEq';
|
||
import type { Spectrum } from '../../viewport/spectrum';
|
||
import { crispText, pressCount, Theme, textStyle, Widget } from '../../ui';
|
||
import { ViewTween } from '../viewTween';
|
||
import {
|
||
apartLabels,
|
||
bandAt,
|
||
EQ_GRID_FREQUENCIES,
|
||
eqGridDb,
|
||
handleOf,
|
||
SPECTRUM_FLOOR_DB,
|
||
spectrumPeak,
|
||
spectrumYs,
|
||
wheeledQ,
|
||
wheeledRange,
|
||
} from './eqView';
|
||
|
||
/** The least the curve shrinks to; otherwise it takes what its panel leaves it. */
|
||
const MIN_CURVE_WIDTH = 160;
|
||
const MIN_CURVE_HEIGHT = 80;
|
||
const HANDLE_RADIUS = 7;
|
||
const GRAB_RADIUS = HANDLE_RADIUS + 4;
|
||
/** Pixels of travel before a press on a band becomes a drag. */
|
||
const DRAG_THRESHOLD = 3;
|
||
/** Points the curve is drawn through, evenly in octaves. */
|
||
const CURVE_STEPS = 320;
|
||
const BAND_COLORS = [
|
||
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
||
];
|
||
/** Pixels between the points the spectrum is drawn through (#374). */
|
||
const SPECTRUM_STEP = 2;
|
||
/** Pixels kept between two labels on an axis; one that comes closer is left out (#385). */
|
||
const LABEL_GAP = 4;
|
||
|
||
/** What the curve asks of the panel around it. */
|
||
export interface CurveHost {
|
||
bands(): readonly PassBand[];
|
||
range(): number;
|
||
zoom(range: number): void;
|
||
readonly picked: number;
|
||
pick(index: number): void;
|
||
/** A band changed as a drag goes, written into the node for a preview. */
|
||
drag(index: number, change: Partial<PassBand>): void;
|
||
/** The drag is over: its undo step. */
|
||
drop(): void;
|
||
/** A band changed as an undo step of its own. */
|
||
set(index: number, change: Partial<PassBand>): void;
|
||
/** A double-click on the free curve; false when the panel takes no band there. */
|
||
addBand(frequency: number, gain: number): boolean;
|
||
/** A right-click on a band; false when the band stays. */
|
||
removeBand(index: number): boolean;
|
||
}
|
||
|
||
export class BandCurve extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly grid = new Graphics();
|
||
/** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */
|
||
private readonly spectrum = new Graphics();
|
||
private readonly lines = new Graphics();
|
||
private readonly handles = new Graphics();
|
||
private readonly labels: Text[] = [];
|
||
/** As many as the most lines a range has; those of a range with fewer are hidden. */
|
||
private readonly dbLabels: Text[] = [];
|
||
private readonly numbers: Text[] = [];
|
||
private dragging: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
|
||
private heard: Spectrum | null = null;
|
||
/** The dB drawn while a zoom is on its way (#377); null draws the node's. */
|
||
private shownRange: number | null = null;
|
||
private readonly tween = new ViewTween({
|
||
read: () => ({ scale: 1 / this.range, x: 0, y: 0 }),
|
||
write: (view) => (this.shownRange = 1 / view.scale),
|
||
draw: () => {
|
||
if (!this.tween.moving) this.shownRange = null;
|
||
this.redraw();
|
||
},
|
||
});
|
||
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
|
||
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
|
||
|
||
constructor(private readonly host: CurveHost) {
|
||
super();
|
||
this.minWidth = MIN_CURVE_WIDTH;
|
||
this.minHeight = MIN_CURVE_HEIGHT;
|
||
this.cursor = 'crosshair';
|
||
this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles);
|
||
const muted = textStyle('small').clone();
|
||
muted.fill = Theme.colors.textMuted;
|
||
const onHandle = textStyle('small').clone();
|
||
onHandle.fill = Theme.colors.symbolOutline;
|
||
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
|
||
const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length));
|
||
for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted }));
|
||
for (let i = 0; i < MAX_EQ_BANDS; i++)
|
||
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle }));
|
||
for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) {
|
||
text.eventMode = 'none';
|
||
this.addChild(text);
|
||
}
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
|
||
this.onPointerRelease(() => this.release());
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e));
|
||
}
|
||
|
||
get area(): EqArea {
|
||
return { w: this.w, h: this.h, range: this.range };
|
||
}
|
||
|
||
private get range(): number {
|
||
return this.shownRange ?? this.host.range();
|
||
}
|
||
|
||
/** The dB drawn and whether a zoom is on its way – the check scripts sample it. */
|
||
get zoomShown(): { range: number; moving: boolean } {
|
||
return { range: this.range, moving: this.tween.moving };
|
||
}
|
||
|
||
/** The axis labels drawn, for the checks: those that would run into another are left out. */
|
||
get shownLabels(): { frequencies: string[]; dbs: string[] } {
|
||
const shown = (texts: Text[]) => texts.filter((t) => t.visible).map((t) => t.text);
|
||
return { frequencies: shown(this.labels), dbs: shown(this.dbLabels) };
|
||
}
|
||
|
||
/** Another node: a zoom on its way to the one before ends. */
|
||
settle(): void {
|
||
this.tween.land();
|
||
this.shownRange = null;
|
||
}
|
||
|
||
/** Where a band's handle is on screen – the check scripts aim with it. */
|
||
handleGlobal(index: number): { x: number; y: number } | null {
|
||
const band = this.host.bands()[index];
|
||
if (!band) return null;
|
||
const at = this.toGlobal(handleOf(band, this.area));
|
||
return { x: at.x, y: at.y };
|
||
}
|
||
|
||
/** Where a frequency and a gain are on screen, at the graph's zoom in the node. */
|
||
pointGlobal(frequency: number, db: number): { x: number; y: number } {
|
||
const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) });
|
||
return { x: at.x, y: at.y };
|
||
}
|
||
|
||
private local(e: FederatedPointerEvent): { x: number; y: number } {
|
||
return this.toLocal(e.global);
|
||
}
|
||
|
||
private press(e: FederatedPointerEvent): void {
|
||
const at = this.local(e);
|
||
const bands = this.host.bands();
|
||
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||
if (e.button === 2) {
|
||
if (index >= 0 && this.host.removeBand(index)) e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
// In the node a press on the curve is the curve's: it neither picks nor moves the node.
|
||
e.stopPropagation();
|
||
if (index >= 0) {
|
||
this.host.pick(index);
|
||
this.dragging = { index, startX: e.global.x, startY: e.global.y, moved: false };
|
||
return;
|
||
}
|
||
// A double-click on the free curve may put a band there; a single one lets go of the picked one.
|
||
if (pressCount(e, this) === 2) {
|
||
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||
if (this.host.addBand(frequency, Math.round(yToDb(at.y, this.area) * 10) / 10)) return;
|
||
}
|
||
this.host.pick(-1);
|
||
}
|
||
|
||
private move(e: FederatedPointerEvent): void {
|
||
const drag = this.dragging;
|
||
if (!drag) return;
|
||
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
|
||
return;
|
||
drag.moved = true;
|
||
const at = this.local(e);
|
||
const band = this.host.bands()[drag.index];
|
||
if (!band) return;
|
||
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
|
||
this.host.drag(drag.index, hasGain(band.type) ? { frequency, gain } : { frequency });
|
||
}
|
||
|
||
private release(): void {
|
||
const drag = this.dragging;
|
||
this.dragging = null;
|
||
if (drag?.moved) this.host.drop();
|
||
}
|
||
|
||
/**
|
||
* The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in
|
||
* the tab: in the node the graph takes every wheel before the curve sees it (#383).
|
||
*/
|
||
private wheel(e: FederatedWheelEvent): void {
|
||
if (e.deltaY === 0) return;
|
||
const at = this.toLocal(e.global);
|
||
const bands = this.host.bands();
|
||
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||
const band = bands[index];
|
||
e.stopPropagation();
|
||
if (!band) {
|
||
const range = wheeledRange(this.host.range(), e.deltaY);
|
||
this.tween.change(() => {
|
||
this.shownRange = null;
|
||
this.host.zoom(range);
|
||
});
|
||
return;
|
||
}
|
||
if (!hasQ(band.type)) return;
|
||
this.host.pick(index);
|
||
this.host.set(index, { q: wheeledQ(band.q, e.deltaY) });
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
const area = this.area;
|
||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
|
||
|
||
this.grid.clear();
|
||
const frequencySpans = EQ_GRID_FREQUENCIES.map(({ hz }, i) => {
|
||
const x = Math.round(frequencyToX(hz, area)) + 0.5;
|
||
this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||
const text = this.labels[i]!;
|
||
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
|
||
return { from: text.x, to: text.x + text.width };
|
||
});
|
||
apartLabels(frequencySpans, LABEL_GAP).forEach((shown, i) => (this.labels[i]!.visible = shown));
|
||
// The lines of the range a zoom is heading to, so they slide to their place with whole numbers.
|
||
const dbs = eqGridDb(this.host.range());
|
||
const dbSpans: { from: number; to: number }[] = [];
|
||
const placed: Text[] = [];
|
||
this.dbLabels.forEach((text, i) => {
|
||
const db = dbs[i];
|
||
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
|
||
text.visible = y > 0 && y < this.h;
|
||
if (!text.visible || db === undefined) return;
|
||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||
text.text = db > 0 ? `+${db}` : `${db}`;
|
||
text.position.set(3, y - text.height - 1);
|
||
dbSpans.push({ from: text.y, to: text.y + text.height });
|
||
placed.push(text);
|
||
});
|
||
apartLabels(dbSpans, 0).forEach((shown, i) => (placed[i]!.visible = shown));
|
||
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
|
||
const zero = Math.round(dbToY(0, area)) + 0.5;
|
||
this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 });
|
||
|
||
const bands = this.host.bands();
|
||
this.lines.clear();
|
||
const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2);
|
||
const xs = curves.xs;
|
||
curves.bands.forEach((ys, i) => {
|
||
trace(this.lines, xs, ys);
|
||
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||
});
|
||
for (const shape of fillShapes(xs, curves.sum, zero))
|
||
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||
trace(this.lines, xs, curves.sum);
|
||
this.lines.stroke({ color: c.accent, width: 2 });
|
||
|
||
this.handles.clear();
|
||
this.numbers.forEach((text, i) => {
|
||
const band = bands[i];
|
||
text.visible = Boolean(band);
|
||
if (!band) return;
|
||
const at = handleOf(band, area);
|
||
this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]);
|
||
if (i === this.host.picked)
|
||
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
|
||
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
|
||
});
|
||
this.drawSpectrum();
|
||
}
|
||
|
||
/** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */
|
||
showSpectrum(spectrum: Spectrum | null): void {
|
||
const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz);
|
||
const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz);
|
||
const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB;
|
||
const shown = loud ? spectrum : null;
|
||
if (!shown && !this.heard) return;
|
||
this.heard = shown;
|
||
if (shown)
|
||
this.seen = {
|
||
frames: this.seen.frames + 1,
|
||
peakIn: peakIn?.hz ?? 0,
|
||
peakOut: peakOut?.hz ?? 0,
|
||
levelOut: peakOut?.db ?? -Infinity,
|
||
binHz: shown.binHz,
|
||
};
|
||
this.drawSpectrum();
|
||
}
|
||
|
||
get hasSpectrum(): boolean {
|
||
return this.heard !== null;
|
||
}
|
||
|
||
private drawSpectrum(): void {
|
||
this.spectrum.clear();
|
||
const heard = this.heard;
|
||
if (!heard) return;
|
||
const c = Theme.colors;
|
||
const count = Math.floor(this.w / SPECTRUM_STEP);
|
||
const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w);
|
||
for (const [dbs, color, alpha, line] of [
|
||
[heard.in, c.textMuted, 0.12, false],
|
||
[heard.out, c.portAudio, 0.18, true],
|
||
] as const) {
|
||
const ys = spectrumYs(dbs, heard.binHz, xs, this.area);
|
||
this.spectrum
|
||
.poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h])
|
||
.fill({ color, alpha });
|
||
if (!line) continue;
|
||
trace(this.spectrum, xs, ys);
|
||
this.spectrum.stroke({ color, width: 1, alpha: 0.6 });
|
||
}
|
||
}
|
||
}
|
||
|
||
/** A path through the points. */
|
||
function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void {
|
||
g.moveTo(xs[0]!, ys[0]!);
|
||
for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!);
|
||
}
|