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:
1 parent
17f47f6956
commit
c087993000
26 files changed
+1514
-656
No files matched your search
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user