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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 14:30:00 +02:00
1 parent 17f47f6956
commit c087993000
26 files changed
+1514 -656

No files matched your search

+348
View File
@@ -0,0 +1,348 @@
/**
* 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]!);
}
@@ -1,24 +1,22 @@
import { tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { EqPanel } from './EqPanel';
import { CURVE_NODE_MIN, type CurvePanel } from './CurvePanel';
/**
* A Parametric EQ node drawn as its editor (#381): the panel of its tab, worked right in the graph,
* with the spectrum behind the curve while the node is in the picture.
* A Parametric EQ node (#381) or a Filter node (#385) drawn as its editor: the panel of its tab,
* worked right in the graph, with the spectrum behind the curve while the node is in the picture.
* It can be dragged to another size (#385).
*/
export class EqFace implements NodeFace {
readonly widget: EqPanel;
export class CurveFace implements NodeFace {
readonly minSize = CURVE_NODE_MIN;
private listening = false;
constructor(
state: EditorState,
readonly widget: CurvePanel,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new EqPanel(state, true);
}
) {}
sync(): void {
this.widget.show(this.assetId, this.nodeId);
+353
View File
@@ -0,0 +1,353 @@
/**
* What the editors of a Parametric EQ (#367) and of a Filter (#385) share: the curve, the numbers of
* the picked band in a row under it and, in a tab, a line of help. A tab shows the panel, and so
* does the node in the graph (#381), at the size the node was dragged to (#385).
*
* Too little room leaves things out rather than cutting them (#385): the row comes in only once the
* curve keeps `CURVE_ROOM` above it, the help only once the row has come in too, and a group of the
* row that does not fit is not drawn – the one on the right goes first.
*/
import type { FederatedPointerEvent } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import type { FxPassFilter } from '../../core/soundfx/nodes';
import {
EQ_FREQUENCY,
EQ_Q,
EQ_RANGE_PARAM,
EQ_SLOPES,
eqRangeOf,
hasGain,
hasQ,
hasSlope,
type PassBand,
} from '../../core/soundfx/parametricEq';
import {
Dropdown,
HStack,
Label,
Menu,
NumberField,
type Size,
Spacer,
Theme,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import { BandCurve, type CurveHost } from './BandCurve';
import { fittingCount, shownRows } from './eqView';
/** The height the curve keeps before the row under it comes in (#385). */
export const CURVE_ROOM = 120;
/** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */
export const CURVE_NODE_MIN: Size = { w: 240, h: 120 };
/** A row of groups, drawn from the left as far as they fit; the rest are not drawn at all (#385). */
class FittingRow extends HStack {
private readonly names: string[] = [];
private groups(): Widget[] {
return this.children.filter((c): c is Widget => c instanceof Widget);
}
addGroup(name: string, group: HStack): HStack {
this.names.push(name);
return this.add(group);
}
override measure(): Size {
const sizes = this.groups().map((group) => group.measure());
const w = sizes.reduce((sum, size) => sum + size.w, 0) + Math.max(0, sizes.length - 1) * this.gap;
const h = Math.max(0, ...sizes.map((size) => size.h));
return {
w: Math.max(this.minWidth, w + this.padding * 2),
h: Math.max(this.minHeight, h + this.padding * 2),
};
}
override layout(): void {
const groups = this.groups();
const fit = fittingCount(
groups.map((group) => group.measure().w),
this.gap,
this.w - this.padding * 2,
);
groups.forEach((group, i) => (group.visible = i < fit));
super.layout();
}
/** The groups drawn, by name – the check scripts read it. */
get shown(): string[] {
return this.names.filter((_, i) => this.groups()[i]?.visible);
}
}
export interface CurvePanelOptions {
/** What the panel is at in a node until the node is dragged to another size. */
nodeSize: Size;
types: readonly { value: FxPassFilter; label: string }[];
gain: { min: number; max: number };
help: string;
/** A group before the type, and one at the right end of the row. */
lead?: Widget;
end?: Widget;
}
export abstract class CurvePanel extends VStack implements CurveHost {
readonly curve: BandCurve;
protected assetId = '';
protected nodeId = '';
picked = -1;
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
protected readonly row = new FittingRow({ gap: Theme.gap, align: 'center' });
private readonly help: Label | null;
/** What the node held before a drag or a scrub began – its undo step's other side. */
private readonly before = new Map<string, PropertyValue | undefined>();
private readonly perFrame: FramePreview;
constructor(
protected readonly state: EditorState,
readonly inNode: boolean,
private readonly options: CurvePanelOptions,
frameName: string,
) {
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
this.perFrame = new FramePreview(state, frameName);
this.curve = new BandCurve(this);
const numbers = (change: (value: number) => Partial<PassBand>) => ({
onInput: (value: number) => this.drag(this.picked, change(value)),
onChange: (value: number) => this.set(this.picked, change(value)),
});
this.typeButton = new Dropdown('—', { width: 104, onClick: () => this.openTypeMenu() });
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
this.frequencyField = new NumberField(1000, {
width: 64,
min: EQ_FREQUENCY.min,
max: EQ_FREQUENCY.max,
step: 1,
decimals: 0,
...numbers((frequency) => ({ frequency })),
});
this.gainField = new NumberField(0, {
width: 52,
min: options.gain.min,
max: options.gain.max,
step: 0.1,
decimals: 1,
...numbers((gain) => ({ gain })),
});
this.qField = new NumberField(1, {
width: 52,
min: EQ_Q.min,
max: EQ_Q.max,
step: 0.01,
decimals: 3,
...numbers((q) => ({ q })),
});
// In the node a press on a menu button is not one on the node: it neither picks nor moves it.
if (inNode)
for (const button of [this.typeButton, this.slopeButton])
button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
if (options.lead) this.group('Band', options.lead);
for (const [name, widget] of [
['Type', this.typeButton],
['Hz', this.frequencyField],
['dB', this.gainField],
['Q', this.qField],
['Slope', this.slopeButton],
] as const)
this.group(name, new Label(name, { style: 'small' }), widget);
if (options.end) {
// The last group takes what is left, so what it holds sits at the right end.
const spacer = new Spacer();
const end = this.group('End', spacer, options.end);
end.flex = 1;
spacer.flex = 1;
}
this.help = inNode ? null : new Label(options.help, { style: 'muted' });
this.add(this.curve, 1);
this.add(this.row);
if (this.help) this.add(this.help);
}
/** A group of the row: a name and what it names, drawn or left out together. */
private group(name: string, ...widgets: Widget[]): HStack {
const group = new HStack({ gap: Theme.gap, align: 'center' });
for (const widget of widgets) group.add(widget);
return this.row.addGroup(name, group);
}
override measure(): Size {
return this.inNode ? this.options.nodeSize : super.measure();
}
override layout(): void {
const rows = this.help ? [this.row, this.help] : [this.row];
const shown = shownRows(
this.h - this.padding * 2,
CURVE_ROOM,
rows.map((row) => row.measure().h),
this.gap,
);
rows.forEach((row, i) => (row.visible = i < shown));
super.layout();
}
/** What the panel draws under the curve, for the checks: the groups of the row and the help. */
get shownRows(): { row: string[]; help: boolean } {
return { row: this.row.visible ? this.row.shown : [], help: this.help?.visible ?? false };
}
/** Points the panel at a node; what was picked stays when it is the same node again. */
show(assetId: string, nodeId: string): void {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle();
this.perFrame.cancel();
this.curve.showSpectrum(null);
this.before.clear();
this.assetId = assetId;
this.nodeId = nodeId;
this.picked = this.bands().length > 0 ? 0 : -1;
}
this.sync();
}
/** The panel lets go of its node. */
close(): void {
this.perFrame.cancel();
this.curve.showSpectrum(null);
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
this.nodeId = '';
this.picked = -1;
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
protected abstract node(): GraphNode | null;
abstract bands(): PassBand[];
abstract drag(index: number, change: Partial<PassBand>): void;
abstract drop(): void;
abstract set(index: number, change: Partial<PassBand>): void;
addBand(_frequency: number, _gain: number): boolean {
return false;
}
removeBand(_index: number): boolean {
return false;
}
/** Which of the picked band's numbers are fed by a link and cannot be typed or dragged. */
protected locked(): { frequency: boolean; gain: boolean; q: boolean } {
return { frequency: false, gain: false, q: false };
}
range(): number {
return eqRangeOf(this.node()?.params ?? {});
}
zoom(range: number): void {
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
}
pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.sync();
}
/** Writes values into the node as they are dragged; `commit` makes the undo step of them. */
protected preview(values: Record<string, PropertyValue>): void {
const node = this.node();
if (!node) return;
for (const [key, value] of Object.entries(values)) {
if (!this.before.has(key)) this.before.set(key, node.params[key]);
node.params[key] = value;
}
this.perFrame.request();
}
/** The values as one undo step, after what a drag wrote is put back; none when nothing changes. */
protected commit(values: Record<string, PropertyValue>, label: string): void {
this.perFrame.cancel();
const node = this.node();
if (!node) return;
this.restore(node);
const commands = Object.entries(values)
.filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(node.params[key]))
.map(([key, value]) => new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
if (commands.length === 0) this.state.preview();
else this.state.execute(oneStep(label, commands));
}
/** What a drag wrote as one undo step. */
protected commitPreview(label: string): void {
const node = this.node();
if (!node) return;
const values: Record<string, PropertyValue> = {};
for (const key of this.before.keys()) if (node.params[key] !== undefined) values[key] = node.params[key];
this.commit(values, label);
}
private restore(node: GraphNode): void {
for (const [key, value] of this.before) {
if (value === undefined) delete node.params[key];
else node.params[key] = value;
}
this.before.clear();
}
private openTypeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = this.options.types.map((type) => ({
label: type.label,
current: type.value === band.type,
onSelect: () => this.set(this.picked, { type: type.value }),
}));
new Menu(items).openBelow(this.typeButton);
}
private openSlopeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_SLOPES.map((slope) => ({
label: `${slope} dB/oct`,
current: slope === band.slope,
onSelect: () => this.set(this.picked, { slope }),
}));
new Menu(items).openBelow(this.slopeButton);
}
/** After every change to the model: the row shows the picked band as it now is. */
sync(): void {
if (!this.node()) return;
const bands = this.bands();
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const locked = this.locked();
this.typeButton.setText(this.options.types.find((t) => t.value === band?.type)?.label ?? '—');
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
this.frequencyField.setNumber(band?.frequency ?? 1000);
this.gainField.setNumber(band?.gain ?? 0);
this.qField.setNumber(band?.q ?? 1);
this.typeButton.setEnabled(Boolean(band));
this.frequencyField.setEnabled(Boolean(band && !locked.frequency));
this.gainField.setEnabled(Boolean(band && hasGain(band.type) && !locked.gain));
this.qField.setEnabled(Boolean(band && hasQ(band.type) && !locked.q));
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
this.curve.invalidate();
}
}
+27 -16
View File
@@ -1,27 +1,35 @@
/**
* The tab of a Parametric EQ node (#370): the panel of `EqPanel.ts` under a header, pointed at the
* node whose tab is showing. While the tab is seen, the spectrum of what passes the node is drawn
* behind the curve (#374).
* The tab of a Parametric EQ node (#370) or a Filter node (#385): its panel under a header, pointed
* at the node whose tab is showing. While the tab is seen, the spectrum of what passes the node is
* drawn behind the curve (#374, #385).
*/
import { findGraphAsset, type Project } from '../../core/model/Project';
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
import { SoundFxFilter, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { Label, Spacer, VStack } from '../../ui';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
import type { CurvePanel } from './CurvePanel';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
const TITLES = { eq: 'Parametric EQ', filter: 'Filter' } as const;
/** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */
export class EqEditor extends VStack {
readonly panel: EqPanel;
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
readonly panel: CurvePanel;
private readonly title: Label;
private readonly header = new TabHeader();
/** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */
private listening = false;
constructor(private readonly state: EditorState) {
constructor(
private readonly state: EditorState,
private readonly kind: 'eq' | 'filter' = 'eq',
) {
super();
this.panel = new EqPanel(state);
this.panel = kind === 'eq' ? new EqPanel(state) : new FilterPanel(state);
this.title = new Label(TITLES[kind], { style: 'bodyBold' });
this.header.add(this.title);
this.header.add(new Spacer(), 1);
this.add(this.header);
@@ -69,16 +77,16 @@ export class EqEditor extends VStack {
return this.panel.curve.hasSpectrum;
}
/** Delete in the tab takes the picked band – never the node the tab is about. */
/** Delete in an EQ's tab takes the picked band – never the node the tab is about. */
removePicked(): void {
this.panel.removePicked();
if (this.panel instanceof EqPanel) this.panel.removePicked();
}
private sync(): void {
const shown = this.panel.shownFor;
if (!shown) return;
this.panel.sync();
const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
const title = `${TITLES[this.kind]} – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
if (title === this.title.text) return;
this.title.setText(title);
this.relayout();
@@ -86,12 +94,15 @@ export class EqEditor extends VStack {
}
/**
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
* What an EQ's or a Filter's tab says: its asset's name, and which one it is when the asset has
* more than one of them.
*/
export function eqTabLabel(project: Project, assetId: string, nodeId: string): string {
const asset = findGraphAsset(project, assetId);
if (!asset) return '';
const eqs = asset.graph.nodes.filter((n) => n.type === SoundFxParametricEq.type);
const index = eqs.findIndex((n) => n.id === nodeId);
return eqs.length > 1 ? `${asset.name} · EQ ${index + 1}` : `${asset.name} · EQ`;
const type = asset?.graph.nodes.find((n) => n.id === nodeId)?.type;
if (!asset || !type) return '';
const noun = type === SoundFxFilter.type ? 'Filter' : 'EQ';
const same = asset.graph.nodes.filter((n) => n.type === type);
const index = same.findIndex((n) => n.id === nodeId);
return same.length > 1 ? `${asset.name} · ${noun} ${index + 1}` : `${asset.name} · ${noun}`;
}
+65 -568
View File
@@ -1,506 +1,58 @@
/**
* The editor of a Parametric EQ node (#367): the curve and the numbers of the picked band. Its tab
* (#370) shows it, and since #381 so does the node itself in the graph. The curve is the sum of the
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
* undo step. 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 is
* drawn behind the curve (#374).
* The editor of a Parametric EQ node (#367): the curve of its bands and the numbers of the picked
* one. Its tab (#370) shows it, and since #381 so does the node itself in the graph. A double-click
* on the free curve adds a band, a right-click on one removes it. While it is seen, the spectrum of
* what passes the node is drawn behind the curve (#374).
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import {
dbToY,
type EqArea,
type EqBand,
EQ_BAND_TYPES,
EQ_BANDS_PARAM,
EQ_FREQUENCY,
EQ_GAIN,
EQ_Q,
EQ_RANGE_PARAM,
EQ_RANGES,
EQ_SLOPES,
eqBandsOf,
eqBandsValue,
eqCurvePoints,
eqRangeOf,
fillShapes,
frequencyToX,
hasGain,
hasQ,
hasSlope,
MAX_EQ_BANDS,
newEqBand,
type PassBand,
withBandSet,
xToFrequency,
yToDb,
} from '../../core/soundfx/parametricEq';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { Spectrum } from '../../viewport/spectrum';
import {
crispText,
Dropdown,
HStack,
Label,
Menu,
NumberField,
pressCount,
Spacer,
Theme,
textStyle,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import { Label } from '../../ui';
import type { EditorState } from '../state';
import { ViewTween } from '../viewTween';
import {
bandAt,
EQ_GRID_FREQUENCIES,
eqGridDb,
handleOf,
SPECTRUM_FLOOR_DB,
spectrumPeak,
spectrumYs,
wheeledQ,
wheeledRange,
} from './eqView';
import { CurvePanel } from './CurvePanel';
/** The least the curve shrinks to; otherwise it takes the tab. */
const MIN_CURVE_WIDTH = 320;
const MIN_CURVE_HEIGHT = 160;
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;
/** The panel in the node (#381): a little narrower than the Compressor's, and taller. */
export const EQ_NODE_SIZE = { w: 720, h: 360 };
/** What the curve asks of the panel around it. */
interface EqHost {
bands(): EqBand[];
range(): number;
zoom(range: number): void;
picked: number;
pick(index: number): void;
preview(bands: EqBand[]): void;
commit(bands: EqBand[]): void;
}
class EqCurve 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 drag: { 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: EqHost) {
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 };
}
/** 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) {
e.stopPropagation();
this.host.pick(-1);
this.host.commit(bands.filter((_, i) => i !== index));
}
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.drag = { index, startX: e.global.x, startY: e.global.y, moved: false };
return;
}
// A double-click on the free curve puts a band there; a single one lets go of the picked one.
if (pressCount(e, this) === 2 && bands.length < MAX_EQ_BANDS) {
const band = newEqBand(
Math.round(xToFrequency(at.x, this.area)),
Math.round(yToDb(at.y, this.area) * 10) / 10,
);
this.host.commit([...bands, band]);
this.host.pick(bands.length);
return;
}
this.host.pick(-1);
}
private move(e: FederatedPointerEvent): void {
const drag = this.drag;
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 bands = this.host.bands();
const band = 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.preview(
withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }),
);
}
private release(): void {
const drag = this.drag;
this.drag = null;
if (!drag?.moved) return;
this.host.commit(this.host.bands());
}
/**
* 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.commit(withBandSet(bands, 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();
let label = 0;
for (const { hz } of EQ_GRID_FREQUENCIES) {
const x = Math.round(frequencyToX(hz, area)) + 0.5;
this.grid.moveTo(x, 0).lineTo(x, this.h);
const text = this.labels[label++]!;
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
}
// 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());
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);
});
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]!);
}
/**
* The curve and the picked band's numbers, pointed at one node. The tab points its one panel at the
* node it shows; a node in the graph has a panel of its own at `EQ_NODE_SIZE`.
*/
export class EqPanel extends VStack implements EqHost {
readonly curve: EqCurve;
private assetId = '';
private nodeId = '';
picked = -1;
/** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */
private before: PropertyValue | undefined | null = null;
private readonly bandLabel = new Label('', { style: 'small' });
private readonly count = new Label('', { style: 'muted' });
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
readonly frequencyField: NumberField;
readonly gainField: NumberField;
readonly qField: NumberField;
private readonly row = new HStack({ gap: Theme.gap, align: 'center' });
private readonly perFrame: FramePreview;
/** The texts the panel was last laid out for; a drag changes only numbers and needs no layout. */
export class EqPanel extends CurvePanel {
private readonly bandLabel: Label;
private readonly count: Label;
/** The texts the row was last laid out for; a drag changes only numbers and needs no layout. */
private laidOut = '';
constructor(
private readonly state: EditorState,
readonly inNode = false,
) {
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
this.perFrame = new FramePreview(state, inNode ? 'eq-node' : 'eq-band');
this.curve = new EqCurve(this);
const numbers = (change: (value: number) => Partial<EqBand>) => ({
onInput: (value: number) => this.previewPicked(change(value)),
onChange: (value: number) => this.commitPicked(change(value)),
});
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
this.frequencyField = new NumberField(1000, {
width: 64,
min: EQ_FREQUENCY.min,
max: EQ_FREQUENCY.max,
step: 1,
decimals: 0,
...numbers((frequency) => ({ frequency })),
});
this.gainField = new NumberField(0, {
width: 52,
min: EQ_GAIN.min,
max: EQ_GAIN.max,
step: 0.1,
decimals: 1,
...numbers((gain) => ({ gain })),
});
this.qField = new NumberField(1, {
width: 52,
min: EQ_Q.min,
max: EQ_Q.max,
step: 0.01,
decimals: 3,
...numbers((q) => ({ q })),
});
// In the node a press on a menu button is not one on the node: it neither picks nor moves it.
if (inNode)
for (const button of [this.typeButton, this.slopeButton])
button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
const row = this.row;
row.add(this.bandLabel);
for (const [name, widget] of [
['Type', this.typeButton],
['Hz', this.frequencyField],
['dB', this.gainField],
['Q', this.qField],
['Slope', this.slopeButton],
] as const) {
row.add(new Label(name, { style: 'small' }));
row.add(widget);
}
row.add(new Spacer(), 1);
row.add(this.count);
this.add(this.curve, 1);
this.add(row);
this.add(
new Label(
inNode
? 'Double-click: add a band. Drag: frequency, gain. Right-click: remove.'
: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
{ style: 'muted' },
),
constructor(state: EditorState, inNode = false) {
const bandLabel = new Label('', { style: 'small' });
const count = new Label('', { style: 'muted' });
super(
state,
inNode,
{
nodeSize: EQ_NODE_SIZE,
types: EQ_BAND_TYPES,
gain: EQ_GAIN,
help: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
lead: bandLabel,
end: count,
},
inNode ? 'eq-node' : 'eq-band',
);
this.bandLabel = bandLabel;
this.count = count;
}
override measure(): { w: number; h: number } {
return this.inNode ? EQ_NODE_SIZE : super.measure();
}
/** Points the panel at a node; the band picked stays when it is the same node again. */
show(assetId: string, nodeId: string): void {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle();
this.perFrame.cancel();
this.curve.showSpectrum(null);
this.before = null;
this.assetId = assetId;
this.nodeId = nodeId;
this.picked = this.bands().length > 0 ? 0 : -1;
}
this.sync();
}
/** The panel lets go of its node. */
close(): void {
this.perFrame.cancel();
this.curve.showSpectrum(null);
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
this.nodeId = '';
this.picked = -1;
this.laidOut = '';
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
private node(): GraphNode | null {
protected node(): GraphNode | null {
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
}
@@ -509,119 +61,64 @@ export class EqPanel extends VStack implements EqHost {
return node ? eqBandsOf(node.params) : [];
}
range(): number {
return eqRangeOf(this.node()?.params ?? {});
/** A band's values as `withBandSet` holds them: a shape the EQ has not, like an all-pass, is none. */
private changed(index: number, change: Partial<PassBand>): EqBand[] {
return withBandSet(this.bands(), index, change as Partial<EqBand>);
}
zoom(range: number): void {
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
private commitBands(bands: EqBand[]): void {
this.commit({ [EQ_BANDS_PARAM]: eqBandsValue(bands) }, 'Set bands');
}
pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.sync();
drag(index: number, change: Partial<PassBand>): void {
if (index >= 0) this.preview({ [EQ_BANDS_PARAM]: eqBandsValue(this.changed(index, change)) });
}
/**
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
* panels hear of it once a frame, however fast the mouse reports.
*/
preview(bands: EqBand[]): void {
const node = this.node();
if (!node) return;
if (this.before === null) this.before = node.params[EQ_BANDS_PARAM];
node.params[EQ_BANDS_PARAM] = eqBandsValue(bands);
this.perFrame.request();
drop(): void {
this.commitPreview('Set bands');
}
commit(bands: EqBand[]): void {
this.perFrame.cancel();
const node = this.node();
if (!node) return;
this.restore(node);
const value = eqBandsValue(bands);
if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) {
this.state.preview();
return;
}
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value));
set(index: number, change: Partial<PassBand>): void {
if (index >= 0) this.commitBands(this.changed(index, change));
}
private restore(node: GraphNode): void {
if (this.before === null) return;
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
else node.params[EQ_BANDS_PARAM] = this.before;
this.before = null;
override addBand(frequency: number, gain: number): boolean {
const bands = this.bands();
if (bands.length >= MAX_EQ_BANDS) return false;
this.commitBands([...bands, newEqBand(frequency, gain)]);
this.pick(bands.length);
return true;
}
private previewPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
}
private commitPicked(change: Partial<EqBand>): void {
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
override removeBand(index: number): boolean {
this.pick(-1);
this.commitBands(this.bands().filter((_, i) => i !== index));
return true;
}
/** Delete in the tab takes the picked band – never the node the tab is about. */
removePicked(): void {
const index = this.picked;
if (index < 0) return;
this.picked = -1;
this.commit(this.bands().filter((_, i) => i !== index));
if (this.picked >= 0) this.removeBand(this.picked);
}
private openTypeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_BAND_TYPES.map((type) => ({
label: type.label,
current: type.value === band.type,
onSelect: () => this.commitPicked({ type: type.value }),
}));
new Menu(items).openBelow(this.typeButton);
override close(): void {
super.close();
this.laidOut = '';
}
private openSlopeMenu(): void {
const band = this.bands()[this.picked];
if (!band) return;
const items = EQ_SLOPES.map((slope) => ({
label: `${slope} dB/oct`,
current: slope === band.slope,
onSelect: () => this.commitPicked({ slope }),
}));
new Menu(items).openBelow(this.slopeButton);
}
/** After every change to the model: the panel shows its node as it now is. */
sync(): void {
const node = this.node();
if (!node) return;
const bands = eqBandsOf(node.params);
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
override sync(): void {
if (!this.node()) return;
super.sync();
const count = `${this.bands().length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = this.picked >= 0 ? `Band ${this.picked + 1}` : 'No band';
this.count.setText(count);
this.bandLabel.setText(bandLabel);
this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—');
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
this.frequencyField.setNumber(band?.frequency ?? 1000);
this.gainField.setNumber(band?.gain ?? 0);
this.qField.setNumber(band?.q ?? 1);
this.typeButton.setEnabled(Boolean(band));
this.frequencyField.setEnabled(Boolean(band));
this.gainField.setEnabled(Boolean(band && hasGain(band.type)));
this.qField.setEnabled(Boolean(band && hasQ(band.type)));
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
const texts = [count, bandLabel].join('\n');
if (texts !== this.laidOut) {
this.laidOut = texts;
this.relayout();
// Its own size stays, so the row would not lay its labels out anew by itself.
this.row.relayout();
}
this.curve.invalidate();
if (texts === this.laidOut) return;
this.laidOut = texts;
this.relayout();
// Its own size stays, so the row would not lay its labels out anew by itself.
this.row.relayout();
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The editor of a Filter node (#385): the curve of the Parametric EQ with the Filter as its one band,
* and its numbers under it. Its tab shows it, and so does the node itself in the graph. A number fed
* by a link is the link's: it can be neither typed nor dragged here.
*/
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import {
FILTER_ROWS,
FILTER_SETTINGS,
FILTER_TYPES,
filterBandOf,
SoundFxFilter,
} from '../../core/soundfx/nodes';
import type { PassBand } from '../../core/soundfx/parametricEq';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { CurvePanel } from './CurvePanel';
/** The panel in the node until it is dragged to another size: room for the whole row. */
export const FILTER_NODE_SIZE = { w: 560, h: 280 };
const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 };
/** A number on its step, within its row. */
function held(value: number, row: { min?: number; max?: number }, step: number): number {
const per = Math.round(1 / step);
return Math.max(row.min ?? value, Math.min(row.max ?? value, Math.round(value * per) / per));
}
export class FilterPanel extends CurvePanel {
constructor(state: EditorState, inNode = false) {
super(
state,
inNode,
{
nodeSize: FILTER_NODE_SIZE,
types: FILTER_TYPES,
gain: GAIN,
help: 'Drag: frequency, gain. Wheel: Q on the handle, else zoom.',
},
inNode ? 'filter-node' : 'filter-band',
);
}
protected node(): GraphNode | null {
return filterNodeOf(this.state.project, this.assetId, this.nodeId);
}
bands(): PassBand[] {
const node = this.node();
return node ? [filterBandOf(node.params)] : [];
}
/** Its one band stays picked. */
override pick(): void {}
protected override locked(): { frequency: boolean; gain: boolean; q: boolean } {
const wired = (key: string) => compressorWired(this.state.project, this.assetId, this.nodeId, key);
return {
frequency: wired(FILTER_ROWS.cutoff.key),
gain: wired(FILTER_ROWS.gain.key),
q: wired(FILTER_ROWS.q.key),
};
}
/** A change of the band as what the node holds, without the numbers a link feeds. */
private values(change: Partial<PassBand>): Record<string, PropertyValue> {
const locked = this.locked();
const values: Record<string, PropertyValue> = {};
if (change.frequency !== undefined && !locked.frequency)
values[FILTER_ROWS.cutoff.key] = held(change.frequency, FILTER_ROWS.cutoff, 1);
if (change.gain !== undefined && !locked.gain)
values[FILTER_ROWS.gain.key] = held(change.gain, GAIN, 0.1);
if (change.q !== undefined && !locked.q) values[FILTER_ROWS.q.key] = held(change.q, FILTER_ROWS.q, 0.001);
if (change.type !== undefined) values[FILTER_SETTINGS.type] = change.type;
if (change.slope !== undefined) values[FILTER_SETTINGS.slope] = String(change.slope);
return values;
}
drag(_index: number, change: Partial<PassBand>): void {
const values = this.values(change);
if (Object.keys(values).length > 0) this.preview(values);
}
drop(): void {
this.commitPreview('Set filter');
}
set(_index: number, change: Partial<PassBand>): void {
this.commit(this.values(change), 'Set filter');
}
}
/** The Filter node a panel is about, or null once it, or its asset, is gone. */
export function filterNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxFilter.type ? node : null;
}
+48 -3
View File
@@ -4,7 +4,7 @@
*/
import {
type EqArea,
type EqBand,
type PassBand,
dbToY,
EQ_Q,
EQ_RANGES,
@@ -17,13 +17,19 @@ import {
* Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line.
* A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach.
*/
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
export function handleOf(band: PassBand, area: EqArea): { x: number; y: number } {
const y = dbToY(hasGain(band.type) ? band.gain : 0, area);
return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) };
}
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number {
export function bandAt(
bands: readonly PassBand[],
x: number,
y: number,
area: EqArea,
radius: number,
): number {
let found = -1;
let best = radius * radius;
bands.forEach((band, i) => {
@@ -37,6 +43,45 @@ export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqA
return found;
}
/**
* How many of a row's groups fit into `room`, from the left (#385): those after them are not drawn
* at all, rather than cut or scrolled.
*/
export function fittingCount(widths: readonly number[], gap: number, room: number): number {
let used = 0;
for (let i = 0; i < widths.length; i++) {
used += (i > 0 ? gap : 0) + widths[i]!;
if (used > room) return i;
}
return widths.length;
}
/**
* How many of the rows below the curve a height has room for (#385): each comes in only once the
* curve keeps `curveRoom` above it, the first row first.
*/
export function shownRows(height: number, curveRoom: number, rows: readonly number[], gap: number): number {
let used = curveRoom;
for (let i = 0; i < rows.length; i++) {
used += gap + rows[i]!;
if (used > height) return i;
}
return rows.length;
}
/**
* Which labels along one axis are drawn (#385): each one `gap` clear of the last one drawn, in the
* order given. One that would run into it is left out rather than drawn over it.
*/
export function apartLabels(spans: readonly { from: number; to: number }[], gap: number): boolean[] {
let last: { from: number; to: number } | null = null;
return spans.map((span) => {
if (last && span.from < last.to + gap && span.to > last.from - gap) return false;
last = span;
return true;
});
}
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
export function wheeledQ(q: number, deltaY: number): number {
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
+8 -4
View File
@@ -1,15 +1,19 @@
import type { GraphNode } from '../../core/graph';
import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { CompressorFace } from './CompressorFace';
import { EqFace } from './EqFace';
import { CurveFace } from './CurveFace';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { GainFace } from './GainFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
if (node.type === SoundFxParametricEq.type)
return new CurveFace(new EqPanel(state, true), assetId, node.id);
if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id);
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
return null;
}
+17 -7
View File
@@ -1,27 +1,35 @@
/**
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
* Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset
* and the node (`eq:<asset>:<node>`).
* Parametric EQ (#370), a Compressor (#378) and a Filter (#385). A tab is a document named by its
* kind, the asset and the node (`eq:<asset>:<node>`).
*/
import type { GraphNode } from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
import { eqTabLabel } from './EqEditor';
import { eqNodeOf } from './EqPanel';
import { filterNodeOf } from './FilterPanel';
export type NodeTabKind = 'eq' | 'compressor';
export type NodeTabKind = 'eq' | 'compressor' | 'filter';
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor'];
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor', 'filter'];
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
[SoundFxParametricEq.type]: 'eq',
[SoundFxCompressor.type]: 'compressor',
[SoundFxFilter.type]: 'filter',
};
/** The kind of tab a node opens, or null for one without. */
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
const NODE_OF: Readonly<Record<NodeTabKind, typeof eqNodeOf>> = {
eq: eqNodeOf,
compressor: compressorNodeOf,
filter: filterNodeOf,
};
/** The node a tab is about, or null once it, or its asset, is gone. */
export function nodeOfTab(
project: Project,
@@ -29,9 +37,11 @@ export function nodeOfTab(
assetId: string,
nodeId: string,
): GraphNode | null {
return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId);
return NODE_OF[kind](project, assetId, nodeId);
}
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId);
return kind === 'compressor'
? compressorTabLabel(project, assetId, nodeId)
: eqTabLabel(project, assetId, nodeId);
}