Files
feuerwerk/src/editor/soundfx/BandCurve.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

349 lines
14 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 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]!);
}