Shape a sound with a Parametric EQ and its own curve editor (#367)
A sound FX or an instrument had a 5-Band-EQ with fixed bands and a Filter with one band. The Parametric EQ holds up to ten bands of any type, and a double-click on the node opens an editor that draws the curve they make together. - The bands are a list on the node (`bands`): type, frequency, gain, Q and slope. A hand-edited project is held to the editor's ranges and to ten bands. - Each band compiles to the existing `pass` stage, so the audio core is unchanged. A peak or a shelf at 0 dB is left out. - The curve is worked out in `parametricEq.ts` with the biquad formulas of the Web Audio spec, so the editor draws what is heard. - The editor is a window that floats over the editor without a backdrop, so the tab's Play stays usable. A double-click adds a peak, a drag moves a band, the wheel turns its Q, a right-click removes it. Fields below the curve set the picked band. It opens in the middle of the screen and moves by its title bar. - A drag or a scrub writes the node as a preview and makes one undo step on release. The panels hear of the preview once a frame, and the window lays itself out only when one of its texts changes. - The area under the curve is filled as one shape for each side of 0 dB. As a single shape it crossed itself and came apart. - The curve works out each band's coefficients once and adds the sum up from the bands. A drag took 295 ms of script time in a profile before and 64 ms after. Decided on my own: - Bands are added by a double-click, so a single click can let go of the picked band without adding one. - The cuts are labelled "Low cut" and "High cut" as in an EQ, not "High-pass" and "Low-pass" as on the Filter. - The bands have no input, so no parameter or Envelope moves them. Tests: - Unit parametric-eq (new) and soundfx: 69 green. Counter-probe with no stages emitted: the compile test red. Counter-probe with one fill shape: the fill test red. - e2e 34 (melektaus-atx): ALL PASSED, 96 checks (3 new). - Counter-probe e2e: no stages emitted, the playing check red. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
204c4a41e8
commit
1f13794b0c
9 files changed
+1105
-4
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
import { currentEqEditor } from '../soundfx/EqEditor';
|
||||
import { rectOf } from '../textProbe';
|
||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
|
||||
@@ -114,6 +115,35 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
/** The header's parameter values, and setting one as its field would. */
|
||||
params: () => editor.soundFxEditor.previewValues,
|
||||
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||||
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
|
||||
},
|
||||
/** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */
|
||||
eq: {
|
||||
shown: () => currentEqEditor()?.shownFor ?? null,
|
||||
bands: () => currentEqEditor()?.bands() ?? null,
|
||||
picked: () => currentEqEditor()?.picked ?? null,
|
||||
handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null,
|
||||
pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null,
|
||||
fields: () => {
|
||||
const eq = currentEqEditor();
|
||||
return (
|
||||
eq && {
|
||||
type: eq.typeButton.text,
|
||||
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
|
||||
gain: eq.gainField.enabled ? eq.gainField.value : null,
|
||||
q: eq.qField.enabled ? eq.qField.value : null,
|
||||
frequency: eq.frequencyField.value,
|
||||
}
|
||||
);
|
||||
},
|
||||
typeAt: () => {
|
||||
const eq = currentEqEditor();
|
||||
return eq ? centreOf(eq.typeButton) : null;
|
||||
},
|
||||
closeAt: () => {
|
||||
const eq = currentEqEditor();
|
||||
return eq ? centreOf(eq.closeButton) : null;
|
||||
},
|
||||
},
|
||||
instrument: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
|
||||
@@ -30,6 +30,8 @@ import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||
import { openEqEditor } from '../soundfx/EqEditor';
|
||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||
import { masterRoutingProblems } from '../../core/music/projectRouting';
|
||||
import { routingProblems } from '../../core/music/routing';
|
||||
@@ -669,6 +671,10 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
if (node.type === SoundFxParametricEq.type) {
|
||||
openEqEditor(this.state, this.assetId, node.id);
|
||||
return true;
|
||||
}
|
||||
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
|
||||
if (node.type === ROUTED_ASSET) {
|
||||
const asset = findAsset(this.state.project, node.id);
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* The editor of a Parametric EQ node (#367): a window that floats over the editor and leaves it
|
||||
* usable, so the tab's Play can be pressed while the bands are set. 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.
|
||||
*/
|
||||
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 } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import {
|
||||
type EqBand,
|
||||
EQ_BAND_TYPES,
|
||||
EQ_BANDS_PARAM,
|
||||
EQ_FREQUENCY,
|
||||
EQ_GAIN,
|
||||
EQ_Q,
|
||||
EQ_SLOPES,
|
||||
eqBandResponse,
|
||||
eqBandsOf,
|
||||
eqBandsValue,
|
||||
hasGain,
|
||||
hasQ,
|
||||
hasSlope,
|
||||
isEqBandHeard,
|
||||
MAX_EQ_BANDS,
|
||||
newEqBand,
|
||||
withBandSet,
|
||||
} from '../../core/soundfx/parametricEq';
|
||||
import {
|
||||
Button,
|
||||
crispText,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
NumberField,
|
||||
Popup,
|
||||
pressCount,
|
||||
Spacer,
|
||||
Theme,
|
||||
textStyle,
|
||||
ui,
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import type { EditorState } from '../state';
|
||||
import {
|
||||
bandAt,
|
||||
dbToY,
|
||||
type EqArea,
|
||||
EQ_GRID_DB,
|
||||
EQ_GRID_FREQUENCIES,
|
||||
fillShapes,
|
||||
frequencyToX,
|
||||
handleOf,
|
||||
wheeledQ,
|
||||
xToFrequency,
|
||||
yToDb,
|
||||
} from './eqView';
|
||||
|
||||
const CURVE_WIDTH = 640;
|
||||
const CURVE_HEIGHT = 260;
|
||||
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,
|
||||
];
|
||||
|
||||
/** What the curve asks of the window around it. */
|
||||
interface EqHost {
|
||||
bands(): EqBand[];
|
||||
picked: number;
|
||||
pick(index: number): void;
|
||||
preview(bands: EqBand[]): void;
|
||||
commit(bands: EqBand[]): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
class EqCurve extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly lines = new Graphics();
|
||||
private readonly handles = new Graphics();
|
||||
private readonly labels: Text[] = [];
|
||||
private readonly numbers: Text[] = [];
|
||||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||||
|
||||
constructor(private readonly host: EqHost) {
|
||||
super();
|
||||
this.minWidth = CURVE_WIDTH;
|
||||
this.minHeight = CURVE_HEIGHT;
|
||||
this.cursor = 'crosshair';
|
||||
this.addChild(this.bg, this.grid, 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 }));
|
||||
for (const db of EQ_GRID_DB)
|
||||
this.labels.push(crispText({ text: db > 0 ? `+${db}` : `${db}`, 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.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 };
|
||||
}
|
||||
|
||||
/** 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 = handleOf(band, this.area);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + at.x, y: origin.y + at.y };
|
||||
}
|
||||
|
||||
/** Where a frequency and a gain are on screen. */
|
||||
pointGlobal(frequency: number, db: number): { x: number; y: number } {
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + frequencyToX(frequency, this.area), y: origin.y + dbToY(db, this.area) };
|
||||
}
|
||||
|
||||
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.pick(-1);
|
||||
this.host.commit(bands.filter((_, i) => i !== index));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
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, 'eq-curve') === 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, or anywhere while one is picked, turns its Q. */
|
||||
private wheel(e: FederatedWheelEvent): void {
|
||||
const at = this.toLocal(e.global);
|
||||
const bands = this.host.bands();
|
||||
const under = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||
const index = under >= 0 ? under : this.host.picked;
|
||||
const band = bands[index];
|
||||
if (!band || !hasQ(band.type) || e.deltaY === 0) return;
|
||||
e.stopPropagation();
|
||||
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);
|
||||
}
|
||||
for (const db of EQ_GRID_DB) {
|
||||
const y = Math.round(dbToY(db, area)) + 0.5;
|
||||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||
const text = this.labels[label++]!;
|
||||
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 });
|
||||
|
||||
// Each band once at every step; the sum is added up from them rather than worked out again.
|
||||
const bands = this.host.bands();
|
||||
this.lines.clear();
|
||||
const xs = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => (i / CURVE_STEPS) * this.w);
|
||||
const frequencies = xs.map((x) => xToFrequency(x, area));
|
||||
const yOf = (db: number) => Math.max(-2, Math.min(this.h + 2, dbToY(db, area)));
|
||||
const sum = new Array<number>(xs.length).fill(0);
|
||||
bands.forEach((band, i) => {
|
||||
const response = eqBandResponse(band);
|
||||
const dbs = frequencies.map(response);
|
||||
if (isEqBandHeard(band)) dbs.forEach((db, j) => (sum[j]! += db));
|
||||
this.trace(xs, dbs.map(yOf));
|
||||
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||||
});
|
||||
const ys = sum.map(yOf);
|
||||
for (const shape of fillShapes(xs, ys, zero))
|
||||
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||||
this.trace(xs, ys);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
/** A path through the points. */
|
||||
private trace(xs: readonly number[], ys: readonly number[]): void {
|
||||
this.lines.moveTo(xs[0]!, ys[0]!);
|
||||
for (let i = 1; i < xs.length; i++) this.lines.lineTo(xs[i]!, ys[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One window for the node last double-clicked. It closes with its node, with the project, or by its
|
||||
* own button.
|
||||
*/
|
||||
export class EqWindow extends Popup 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 title = new Label('Parametric EQ', { style: 'bodyBold' });
|
||||
private readonly count = new Label('', { style: 'muted' });
|
||||
private readonly header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
private readonly bandLabel = new Label('', { style: 'small' });
|
||||
readonly typeButton: Dropdown;
|
||||
readonly slopeButton: Dropdown;
|
||||
readonly frequencyField: NumberField;
|
||||
readonly gainField: NumberField;
|
||||
readonly qField: NumberField;
|
||||
readonly removeButton: Button;
|
||||
readonly closeButton: Button;
|
||||
private readonly unsubscribe: (() => void)[] = [];
|
||||
private readonly perFrame: FramePreview;
|
||||
/** The texts the window was last laid out for; a drag changes only numbers and needs no layout. */
|
||||
private laidOut = '';
|
||||
private grab: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ backdrop: false, padding: Theme.padding * 2, gap: Theme.padding });
|
||||
this.perFrame = new FramePreview(state, 'eq-band');
|
||||
this.curve = new EqCurve(this);
|
||||
this.closeButton = new Button('Close', { variant: 'ghost', onClick: () => this.close() });
|
||||
const header = this.header;
|
||||
header.add(this.title);
|
||||
header.add(this.count);
|
||||
header.add(new Spacer(), 1);
|
||||
header.add(this.closeButton);
|
||||
header.cursor = 'move';
|
||||
header.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.target !== header) return;
|
||||
this.grab = { x: e.global.x - this.x, y: e.global.y - this.y };
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (!this.grab) return;
|
||||
const screen = ui().app.screen;
|
||||
this.position.set(
|
||||
Math.round(Math.max(0, Math.min(screen.width - this.w, e.global.x - this.grab.x))),
|
||||
Math.round(Math.max(0, Math.min(screen.height - this.h, e.global.y - this.grab.y))),
|
||||
);
|
||||
});
|
||||
this.onPointerRelease(() => (this.grab = null));
|
||||
|
||||
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 })),
|
||||
});
|
||||
this.removeButton = new Button('Remove', { variant: 'ghost', onClick: () => this.removePicked() });
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
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.removeButton);
|
||||
|
||||
this.content.add(header);
|
||||
this.content.add(this.curve);
|
||||
this.content.add(row);
|
||||
this.content.add(
|
||||
new Label(
|
||||
`Double-click to add a band (up to ${MAX_EQ_BANDS}). Drag a band for its frequency and gain, turn the mouse wheel for its Q, right-click it to remove it.`,
|
||||
{ style: 'muted' },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens for a node, or turns an open window to it. */
|
||||
show(assetId: string, nodeId: string): void {
|
||||
const other = assetId !== this.assetId || nodeId !== this.nodeId;
|
||||
this.assetId = assetId;
|
||||
this.nodeId = nodeId;
|
||||
if (other) this.picked = this.bands().length > 0 ? 0 : -1;
|
||||
if (!this.isOpen) {
|
||||
this.unsubscribe.push(
|
||||
this.state.on('project', () => this.sync()),
|
||||
this.state.on('load', () => this.close()),
|
||||
);
|
||||
this.openCentered();
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get shownFor(): { assetId: string; nodeId: string } | null {
|
||||
return this.isOpen ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
for (const off of this.unsubscribe.splice(0)) off();
|
||||
this.perFrame.cancel();
|
||||
this.laidOut = '';
|
||||
super.close();
|
||||
}
|
||||
|
||||
private node(): GraphNode | null {
|
||||
return (
|
||||
findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find((n) => n.id === this.nodeId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
bands(): EqBand[] {
|
||||
const node = this.node();
|
||||
return node ? eqBandsOf(node.params) : [];
|
||||
}
|
||||
|
||||
pick(index: number): void {
|
||||
if (this.picked === index) return;
|
||||
this.picked = index;
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.perFrame.cancel();
|
||||
const node = this.node();
|
||||
if (node) this.restore(node);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private removePicked(): void {
|
||||
const index = this.picked;
|
||||
if (index < 0) return;
|
||||
this.picked = -1;
|
||||
this.commit(this.bands().filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Follows the node: gone, the window goes with it. */
|
||||
private sync(): void {
|
||||
if (!this.isOpen) return;
|
||||
const node = this.node();
|
||||
if (!node) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
const bands = eqBandsOf(node.params);
|
||||
if (this.picked >= bands.length) this.picked = -1;
|
||||
const band = bands[this.picked];
|
||||
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
|
||||
const title = `Parametric EQ – ${name}`;
|
||||
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
|
||||
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
|
||||
this.title.setText(title);
|
||||
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)));
|
||||
this.removeButton.setEnabled(Boolean(band));
|
||||
const texts = [title, count, bandLabel].join('\n');
|
||||
if (texts !== this.laidOut) {
|
||||
this.laidOut = texts;
|
||||
this.header.relayout();
|
||||
this.content.relayout();
|
||||
}
|
||||
this.curve.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
let eqWindow: EqWindow | null = null;
|
||||
|
||||
/** Opens the editor of a Parametric EQ node; one window, turned to whichever node asked last. */
|
||||
export function openEqEditor(state: EditorState, assetId: string, nodeId: string): EqWindow {
|
||||
eqWindow ??= new EqWindow(state);
|
||||
eqWindow.show(assetId, nodeId);
|
||||
return eqWindow;
|
||||
}
|
||||
|
||||
/** The open window, for the check scripts. */
|
||||
export function currentEqEditor(): EqWindow | null {
|
||||
return eqWindow?.isOpen ? eqWindow : null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* How the Parametric EQ editor (#367) lays its curve onto a picture: frequency to the right in
|
||||
* octaves, from 20 Hz to 20 kHz, and dB up, the same distance either side of 0.
|
||||
*/
|
||||
import { type EqBand, EQ_FREQUENCY, EQ_GAIN, EQ_Q, hasGain } from '../../core/soundfx/parametricEq';
|
||||
|
||||
export interface EqArea {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** The dB the picture shows above and below 0. */
|
||||
export const EQ_VIEW_DB = EQ_GAIN.max;
|
||||
|
||||
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
|
||||
|
||||
export const frequencyToX = (frequency: number, area: EqArea): number =>
|
||||
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
|
||||
|
||||
export const xToFrequency = (x: number, area: EqArea): number =>
|
||||
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
|
||||
|
||||
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / EQ_VIEW_DB) * (area.h / 2);
|
||||
|
||||
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB;
|
||||
|
||||
/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */
|
||||
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
||||
return { x: frequencyToX(band.frequency, area), y: dbToY(hasGain(band.type) ? band.gain : 0, area) };
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
let found = -1;
|
||||
let best = radius * radius;
|
||||
bands.forEach((band, i) => {
|
||||
const at = handleOf(band, area);
|
||||
const d = (at.x - x) ** 2 + (at.y - y) ** 2;
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
found = i;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
|
||||
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
|
||||
* line, and its fill would come apart.
|
||||
*/
|
||||
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
|
||||
const shapes: number[][] = [];
|
||||
let shape = [xs[0]!, zero];
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const after = ys[i]! - zero;
|
||||
const before = i > 0 ? ys[i - 1]! - zero : 0;
|
||||
if (before * after < 0) {
|
||||
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
|
||||
shapes.push([...shape, x, zero]);
|
||||
shape = [x, zero];
|
||||
}
|
||||
shape.push(xs[i]!, ys[i]!);
|
||||
}
|
||||
shapes.push([...shape, xs.at(-1)!, zero]);
|
||||
return shapes;
|
||||
}
|
||||
|
||||
/** The lines the picture is ruled with, and what they say. */
|
||||
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
||||
{ hz: 20, label: '20' },
|
||||
{ hz: 50, label: '50' },
|
||||
{ hz: 100, label: '100' },
|
||||
{ hz: 200, label: '200' },
|
||||
{ hz: 500, label: '500' },
|
||||
{ hz: 1000, label: '1k' },
|
||||
{ hz: 2000, label: '2k' },
|
||||
{ hz: 5000, label: '5k' },
|
||||
{ hz: 10000, label: '10k' },
|
||||
{ hz: 20000, label: '20k' },
|
||||
];
|
||||
export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18];
|
||||
Reference in new issue
Block a user