Edit a Parametric EQ in its node, let the wheel only zoom graphs (#381)
The small curve below the EQ node's rows (#375) only showed the bands. Like the Compressor since #380, the node now shows the editor of its tab between its inputs and its output, and is worked right there. A double-click on the node still opens the same editor in a tab. Two wishes for the nodes with a panel came on top and share the same files, so they are in this commit as well: the numbers below the Compressor's knobs read as plain labels (#382), and the wheel over a node zooms the graph instead of turning a value (#383). #381, the EQ in its node: - EqPanel holds the curve, the band's numbers and the help line, taken out of EqEditor. The tab puts its one panel under a header, each EQ node has a panel of its own at 720 x 360 (EqFace). - A press on the curve neither picks nor moves the node. A double-click on the curve adds a band, one on the title or the edge opens the tab. - In the node a press on the Type or Slope button stays with the button, so the node is not picked or moved. - The spectrum runs in the node while it is in the picture. The tab lets go of the tap once, like the Compressor's, and Editor.tick lets every unseen tab and graph go before any seen one listens. - The count of bands moved from the tab's header into the band row, so the node shows it as well. - The node picture of #375 is gone with its only user: NodePicture, EQ_PICTURE and NodeView's drawing of it. #382, the numbers below the Compressor's knobs in its node: - A Knob with typed: false shows its number as a centred Label without a box, in the panel's ink. The ratio reads 4:1 (ratioText). - The label is as high as its text and sits up in the dial's open bottom, right under the ends of the scale. - Label takes a colour of its own. #383, the wheel in a graph: - GraphCanvas takes the wheel on the way down and stops it there. It zooms over a node too, and no knob or curve in a node sees it. - The tabs keep their wheel: a knob's step, a band's Q and the zoom of the EQ's dB. Found on the way: - A Menu opened below a button in the zoomed graph would have been placed by the button's own height. openBelow now takes the bottom edge as drawn. - The band row did not lay out its labels after a text change when its own size stayed. In the tab a later resize hid it, in the node "Band 1" and the count were missing. The row now lays out itself. - Handle and point positions for the checks were unscaled. They now go through toGlobal and hold at the graph's zoom. Tests: - Unit parametric-eq: 16 tests. The #375 tests now check eqCurvePoints and eqRangeOf directly. compressor-view: ratioText. - e2e 34 (atx): ALL PASSED, 125 checks (3 new: the EQ panel and its ports, editing in the node, the spectrum in the node with the tab open). The Compressor node's check reads its numbers as labels and 8:1. Both node checks now expect the wheel to zoom the graph and leave the ratio, the Q and the dB zoom alone. - The unseen-tab check now leaves for the Scene tab, since the node in the graph listens to the same tap. - Counter-check: the tab letting go of the tap every unseen frame stalls the node's spectrum with the tab open (1 frame against 22, probe on stein2). - Gate (atx): CHECK EXIT 0, 283 files, 4357 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f2b64fb4c
commit
79a04e6764
21 files changed
+1008
-795
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1953 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1956 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -111,21 +111,6 @@ export interface DerivedShape {
|
|||||||
inOrder?: true;
|
inOrder?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A small picture a node draws below its rows (#375), as wide as the node and `height` tall. Its
|
|
||||||
* shapes are flat point lists `[x0, y0, x1, y1, …]` in that box, y down: a `guide` is a faint line, an
|
|
||||||
* `area` is filled, a `line` is drawn strong.
|
|
||||||
*/
|
|
||||||
export interface NodePicture {
|
|
||||||
height: number;
|
|
||||||
shapes(params: PropertyBag, w: number): PictureShape[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PictureShape {
|
|
||||||
kind: 'guide' | 'area' | 'line';
|
|
||||||
points: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||||||
export interface NodeDef {
|
export interface NodeDef {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -150,8 +135,6 @@ export interface NodeDef {
|
|||||||
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
|
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
|
||||||
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
|
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
|
||||||
dimmed?(params: PropertyBag): boolean;
|
dimmed?(params: PropertyBag): boolean;
|
||||||
/** What the node shows of its settings below its rows, drawn rather than written (#375). */
|
|
||||||
picture?: NodePicture;
|
|
||||||
/**
|
/**
|
||||||
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
||||||
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
toneCutoff,
|
toneCutoff,
|
||||||
} from './saturation';
|
} from './saturation';
|
||||||
import { PHASER_STAGES } from './phaser';
|
import { PHASER_STAGES } from './phaser';
|
||||||
import { EQ_PICTURE, eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
||||||
|
|
||||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||||
|
|
||||||
@@ -630,11 +630,10 @@ export const SoundFxParametricEq: SoundFxNodeDef = effect(
|
|||||||
type: 'SoundFxParametricEq',
|
type: 'SoundFxParametricEq',
|
||||||
label: 'Parametric EQ',
|
label: 'Parametric EQ',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve, here in small. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and anywhere else to zoom the dB the curve shows, here as well. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the editor is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
|
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve and edited right in the node: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and right-click it to remove it. Double-click the node's title to open the same in a tab, where the wheel anywhere else zooms the dB the curve shows. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
|
||||||
inputs: [audioIn],
|
inputs: [audioIn],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
picture: EQ_PICTURE,
|
|
||||||
tapped: true,
|
tapped: true,
|
||||||
emit: (sounds, _amounts, settings) =>
|
emit: (sounds, _amounts, settings) =>
|
||||||
withStages(
|
withStages(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
||||||
* editor draws what is heard.
|
* editor draws what is heard.
|
||||||
*/
|
*/
|
||||||
import type { NodePicture, PictureShape } from '../graph/NodeRegistry';
|
|
||||||
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
||||||
import type { FxPassFilter } from './nodes';
|
import type { FxPassFilter } from './nodes';
|
||||||
|
|
||||||
@@ -294,28 +293,3 @@ export function fillShapes(xs: readonly number[], ys: readonly number[], zero: n
|
|||||||
shapes.push([...shape, xs.at(-1)!, zero]);
|
shapes.push([...shape, xs.at(-1)!, zero]);
|
||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How tall the curve is in the node, and through how many points it is drawn there (#375). */
|
|
||||||
const PICTURE_HEIGHT = 60;
|
|
||||||
const PICTURE_STEPS = 96;
|
|
||||||
|
|
||||||
/** The sum of the bands in the node (#375): the 0 dB line, the area to the curve, and the curve. */
|
|
||||||
function eqPictureShapes(bands: readonly EqBand[], area: EqArea): PictureShape[] {
|
|
||||||
const { xs, sum } = eqCurvePoints(bands, area, PICTURE_STEPS);
|
|
||||||
const zero = dbToY(0, area);
|
|
||||||
return [
|
|
||||||
{ kind: 'guide', points: [0, zero, area.w, zero] },
|
|
||||||
...fillShapes(xs, sum, zero).map((points) => ({ kind: 'area' as const, points })),
|
|
||||||
{ kind: 'line', points: xs.flatMap((x, i) => [x, sum[i]!]) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Without a band the box stays, with its 0 dB line: the node does not jump at the first one. It shows
|
|
||||||
* the dB the tab is zoomed to.
|
|
||||||
*/
|
|
||||||
export const EQ_PICTURE: NodePicture = {
|
|
||||||
height: PICTURE_HEIGHT,
|
|
||||||
shapes: (params, w) =>
|
|
||||||
eqPictureShapes(eqBandsOf(params), { w, h: PICTURE_HEIGHT, range: eqRangeOf(params) }),
|
|
||||||
};
|
|
||||||
@@ -1328,16 +1328,16 @@ export class Editor implements PlayHost {
|
|||||||
if (this.shows('song')) this.songEditor.tick();
|
if (this.shows('song')) this.songEditor.tick();
|
||||||
else this.songEditor.hideMeters();
|
else this.songEditor.hideMeters();
|
||||||
if (this.shows('sound')) this.soundEditor.tick();
|
if (this.shows('sound')) this.soundEditor.tick();
|
||||||
if (this.shows('eq')) this.eqEditor.tick();
|
// The tabs of an EQ or a Compressor and their nodes (#380, #381) listen to the same taps: what
|
||||||
else this.eqEditor.hideSpectrum();
|
// is not seen lets go of them first, so it cannot take them from what is.
|
||||||
// A Compressor's tab and its node (#380) listen to the same taps: what is not seen lets go of
|
|
||||||
// them first, so it cannot take them from what is.
|
|
||||||
const faces = [
|
const faces = [
|
||||||
['soundfx', this.soundFxEditor.canvas],
|
['soundfx', this.soundFxEditor.canvas],
|
||||||
['instrument', this.instrumentEditor.canvas],
|
['instrument', this.instrumentEditor.canvas],
|
||||||
] as const;
|
] as const;
|
||||||
|
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
|
||||||
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
|
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
|
||||||
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
|
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
|
||||||
|
if (this.shows('eq')) this.eqEditor.tick();
|
||||||
if (this.shows('compressor')) this.compressorEditor.tick();
|
if (this.shows('compressor')) this.compressorEditor.tick();
|
||||||
this.facesMoving = false;
|
this.facesMoving = false;
|
||||||
for (const [kind, canvas] of faces)
|
for (const [kind, canvas] of faces)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Container } from 'pixi.js';
|
import type { Container } from 'pixi.js';
|
||||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
|
import type { Widget } from '../../ui';
|
||||||
import { currentNodePalette } from '../graph/NodePalette';
|
import { currentNodePalette } from '../graph/NodePalette';
|
||||||
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||||
|
import { EqPanel } from '../soundfx/EqPanel';
|
||||||
import { rectOf } from '../textProbe';
|
import { rectOf } from '../textProbe';
|
||||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||||
|
|
||||||
@@ -125,31 +127,54 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
/** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */
|
/** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */
|
||||||
eq: {
|
eq: {
|
||||||
shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null),
|
shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null),
|
||||||
bands: () => editor.eqEditor.bands(),
|
bands: () => editor.eqEditor.panel.bands(),
|
||||||
picked: () => editor.eqEditor.picked,
|
picked: () => editor.eqEditor.panel.picked,
|
||||||
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
||||||
range: () => editor.eqEditor.range(),
|
range: () => editor.eqEditor.panel.range(),
|
||||||
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
|
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
|
||||||
zoomShown: () => editor.eqEditor.curve.zoomShown,
|
zoomShown: () => editor.eqEditor.panel.curve.zoomShown,
|
||||||
handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
|
handleAt: (index: number) => editor.eqEditor.panel.curve.handleGlobal(index),
|
||||||
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
|
pointAt: (hz: number, db: number) => editor.eqEditor.panel.curve.pointGlobal(hz, db),
|
||||||
fields: () => {
|
fields: () => eqFields(editor.eqEditor.panel),
|
||||||
const eq = editor.eqEditor;
|
typeAt: () => centreOf(editor.eqEditor.panel.typeButton),
|
||||||
return {
|
|
||||||
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: () => centreOf(editor.eqEditor.typeButton),
|
|
||||||
/**
|
/**
|
||||||
* The spectra drawn behind the curve (#374): how many, the loudest bin in front of the node and
|
* The spectra drawn behind the curve (#374): how many, the loudest bin in front of the node and
|
||||||
* behind it in the last one, and the taps – which node is listened to, how many play, how many
|
* behind it in the last one, and the taps – which node is listened to, how many play, how many
|
||||||
* of them feed an analyser.
|
* of them feed an analyser.
|
||||||
*/
|
*/
|
||||||
spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }),
|
spectrum: () => ({ ...editor.eqEditor.panel.curve.seen, taps: soundLibrary.spectrumTaps() }),
|
||||||
|
/**
|
||||||
|
* The panel a Parametric EQ node shows in the sound FX graph (#381): whether it is shown and
|
||||||
|
* where, the node's ports, its bands and fields, where to aim on its curve, and its spectra.
|
||||||
|
*/
|
||||||
|
panel: (nodeId: string) => {
|
||||||
|
const canvas = editor.soundFxEditor.canvas;
|
||||||
|
const panel = canvas.faceOf(nodeId)?.widget;
|
||||||
|
if (!(panel instanceof EqPanel)) return null;
|
||||||
|
const node = canvas.nodeScreen(nodeId);
|
||||||
|
const ports = (keys: string[], isOutput: boolean) =>
|
||||||
|
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
||||||
|
return {
|
||||||
|
shown: shownOnScreen(panel),
|
||||||
|
rect: screenRect(panel),
|
||||||
|
size: { w: panel.w, h: panel.h },
|
||||||
|
curve: screenRect(panel.curve),
|
||||||
|
node: node?.rect ?? null,
|
||||||
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
|
outputs: ports(node?.outputs ?? [], true),
|
||||||
|
bands: panel.bands(),
|
||||||
|
picked: panel.picked,
|
||||||
|
fields: eqFields(panel),
|
||||||
|
handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)),
|
||||||
|
type: screenRect(panel.typeButton),
|
||||||
|
spectrum: panel.curve.seen,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/** Where a frequency and a gain are on the curve of a node's panel, on screen. */
|
||||||
|
panelPointAt: (nodeId: string, hz: number, db: number) => {
|
||||||
|
const panel = editor.soundFxEditor.canvas.faceOf(nodeId)?.widget;
|
||||||
|
return panel instanceof EqPanel ? panel.curve.pointGlobal(hz, db) : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
|
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
|
||||||
compressor: {
|
compressor: {
|
||||||
@@ -160,7 +185,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(editor.compressorEditor.knobs).map(([key, knob]) => [
|
Object.entries(editor.compressorEditor.knobs).map(([key, knob]) => [
|
||||||
key,
|
key,
|
||||||
{ value: knob.value, enabled: knob.enabled, field: knob.field.value },
|
{ value: knob.value, enabled: knob.enabled, field: knob.shown },
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
dialAt: (key: keyof typeof editor.compressorEditor.knobs) =>
|
dialAt: (key: keyof typeof editor.compressorEditor.knobs) =>
|
||||||
@@ -194,18 +219,13 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
const canvas = editor.soundFxEditor.canvas;
|
const canvas = editor.soundFxEditor.canvas;
|
||||||
const panel = canvas.faceOf(nodeId)?.widget;
|
const panel = canvas.faceOf(nodeId)?.widget;
|
||||||
if (!(panel instanceof CompressorPanel)) return null;
|
if (!(panel instanceof CompressorPanel)) return null;
|
||||||
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
|
|
||||||
const node = canvas.nodeScreen(nodeId);
|
const node = canvas.nodeScreen(nodeId);
|
||||||
const ports = (keys: string[], isOutput: boolean) =>
|
const ports = (keys: string[], isOutput: boolean) =>
|
||||||
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
||||||
return {
|
return {
|
||||||
shown: seen(panel),
|
shown: shownOnScreen(panel),
|
||||||
/** On screen, at the graph's zoom; `size` is the panel's own. */
|
/** On screen, at the graph's zoom; `size` is the panel's own. */
|
||||||
rect: (() => {
|
rect: screenRect(panel),
|
||||||
const from = panel.toGlobal({ x: 0, y: 0 });
|
|
||||||
const to = panel.toGlobal({ x: panel.w, y: panel.h });
|
|
||||||
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
|
|
||||||
})(),
|
|
||||||
size: { w: panel.w, h: panel.h },
|
size: { w: panel.w, h: panel.h },
|
||||||
node: node?.rect ?? null,
|
node: node?.rect ?? null,
|
||||||
inputs: ports(node?.inputs ?? [], false),
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
@@ -213,9 +233,8 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
dials: Object.fromEntries(
|
dials: Object.fromEntries(
|
||||||
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
|
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
|
||||||
),
|
),
|
||||||
fields: Object.fromEntries(
|
/** The numbers below the knobs, as they read (#382). */
|
||||||
Object.entries(panel.knobs).map(([key, knob]) => [key, centreOf(knob.field)]),
|
numbers: Object.fromEntries(Object.entries(panel.knobs).map(([key, knob]) => [key, knob.shown])),
|
||||||
),
|
|
||||||
needle: { shown: panel.meter.shown, target: panel.meter.target },
|
needle: { shown: panel.meter.shown, target: panel.meter.target },
|
||||||
leds: panel.leds.litCount,
|
leds: panel.leds.litCount,
|
||||||
seen: panel.seen,
|
seen: panel.seen,
|
||||||
@@ -319,3 +338,26 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a widget and all it sits in are visible. */
|
||||||
|
function shownOnScreen(node: Container | null): boolean {
|
||||||
|
return !node || (node.visible && shownOnScreen(node.parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A widget's box on screen, scaled with whatever it sits in – a node at the graph's zoom. */
|
||||||
|
function screenRect(widget: Widget): { x: number; y: number; w: number; h: number } {
|
||||||
|
const from = widget.toGlobal({ x: 0, y: 0 });
|
||||||
|
const to = widget.toGlobal({ x: widget.w, y: widget.h });
|
||||||
|
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
|
||||||
|
function eqFields(panel: EqPanel) {
|
||||||
|
return {
|
||||||
|
type: panel.typeButton.text,
|
||||||
|
slope: panel.slopeButton.enabled ? panel.slopeButton.text : null,
|
||||||
|
gain: panel.gainField.enabled ? panel.gainField.value : null,
|
||||||
|
q: panel.qField.enabled ? panel.qField.value : null,
|
||||||
|
frequency: panel.frequencyField.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -208,18 +208,25 @@ export class GraphCanvas extends Widget {
|
|||||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||||
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||||
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
// The wheel zooms the graph wherever it turns, over a node too: taken on the way down, before a
|
||||||
const at = this.toLocal(e.global);
|
// knob or curve in a node could turn a value while the graph is zoomed (#383).
|
||||||
this.tween.change(() => {
|
this.addEventListener(
|
||||||
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
|
'wheel',
|
||||||
{ pan: this.pan, zoom: this.zoom },
|
(e: FederatedWheelEvent) => {
|
||||||
at,
|
e.stopPropagation();
|
||||||
zoomFactor(e.deltaY),
|
const at = this.toLocal(e.global);
|
||||||
));
|
this.tween.change(() => {
|
||||||
this.applyView();
|
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
|
||||||
});
|
{ pan: this.pan, zoom: this.zoom },
|
||||||
e.preventDefault();
|
at,
|
||||||
});
|
zoomFactor(e.deltaY),
|
||||||
|
));
|
||||||
|
this.applyView();
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
|
||||||
// A drag can end anywhere; without this the preview would outlive it.
|
// A drag can end anywhere; without this the preview would outlive it.
|
||||||
onDragChange(() => {
|
onDragChange(() => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type NodeCategory,
|
type NodeCategory,
|
||||||
type NodeDef,
|
type NodeDef,
|
||||||
nodeDef,
|
nodeDef,
|
||||||
type NodePicture,
|
|
||||||
type PortDef,
|
type PortDef,
|
||||||
type PortType,
|
type PortType,
|
||||||
type Value,
|
type Value,
|
||||||
@@ -129,8 +128,6 @@ export interface PortHit {
|
|||||||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||||||
export class NodeView extends Widget {
|
export class NodeView extends Widget {
|
||||||
private readonly box = new Graphics();
|
private readonly box = new Graphics();
|
||||||
/** What the node draws of its settings below its rows (#375). */
|
|
||||||
private readonly picture = new Graphics();
|
|
||||||
private readonly ports = new Graphics();
|
private readonly ports = new Graphics();
|
||||||
private readonly texts: Text[] = [];
|
private readonly texts: Text[] = [];
|
||||||
/** The input and output text of each port row, among `texts`. */
|
/** The input and output text of each port row, among `texts`. */
|
||||||
@@ -165,7 +162,6 @@ export class NodeView extends Widget {
|
|||||||
*/
|
*/
|
||||||
private textsOf = '';
|
private textsOf = '';
|
||||||
private drawnOf = '';
|
private drawnOf = '';
|
||||||
private pictureOf = '';
|
|
||||||
/** How wide the sides of a face are, with the pins and their names (#380). */
|
/** How wide the sides of a face are, with the pins and their names (#380). */
|
||||||
private sides = { left: 0, right: 0 };
|
private sides = { left: 0, right: 0 };
|
||||||
/** How often the texts and the box were really made, for the checks. */
|
/** How often the texts and the box were really made, for the checks. */
|
||||||
@@ -189,9 +185,8 @@ export class NodeView extends Widget {
|
|||||||
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
|
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
|
||||||
// nodes were a few hundred draw calls. In the batch they share one.
|
// nodes were a few hundred draw calls. In the batch they share one.
|
||||||
this.box.context.batchMode = 'batch';
|
this.box.context.batchMode = 'batch';
|
||||||
this.picture.context.batchMode = 'batch';
|
|
||||||
this.ports.context.batchMode = 'batch';
|
this.ports.context.batchMode = 'batch';
|
||||||
this.addChild(this.box, this.picture, this.ports);
|
this.addChild(this.box, this.ports);
|
||||||
if (face) this.addChild(face.widget);
|
if (face) this.addChild(face.widget);
|
||||||
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
||||||
}
|
}
|
||||||
@@ -268,14 +263,13 @@ export class NodeView extends Widget {
|
|||||||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||||||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||||||
};
|
};
|
||||||
const picture = this.shownPicture();
|
|
||||||
const face = this.shownFace();
|
const face = this.shownFace();
|
||||||
if (face) this.sides = this.faceSides();
|
if (face) this.sides = this.faceSides();
|
||||||
const height = this.collapsed
|
const height = this.collapsed
|
||||||
? TITLE_HEIGHT
|
? TITLE_HEIGHT
|
||||||
: face
|
: face
|
||||||
? this.headerHeight() + this.faceSize().h + PAD
|
? this.headerHeight() + this.faceSize().h + PAD
|
||||||
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
|
: this.rowsEnd() + PAD;
|
||||||
// Sized before the texts: output labels are placed against the right edge.
|
// Sized before the texts: output labels are placed against the right edge.
|
||||||
this.resize(
|
this.resize(
|
||||||
this.collapsed
|
this.collapsed
|
||||||
@@ -459,42 +453,6 @@ export class NodeView extends Widget {
|
|||||||
face.sync();
|
face.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The node's picture, while the node is open. */
|
|
||||||
private shownPicture(): NodePicture | null {
|
|
||||||
return (!this.collapsed && this.def.picture) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where the picture sits, in node units. */
|
|
||||||
private pictureBox(): { x: number; y: number; w: number; h: number } | null {
|
|
||||||
const picture = this.shownPicture();
|
|
||||||
return picture && { x: PAD, y: this.rowsEnd() + PAD / 2, w: this.w - PAD * 2, h: picture.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drawn again when the settings or the size have changed: a drag in the EQ's tab changes them. */
|
|
||||||
private drawPicture(): void {
|
|
||||||
const picture = this.shownPicture();
|
|
||||||
const box = this.pictureBox();
|
|
||||||
const drawn = picture && box ? JSON.stringify([box, this.node.params]) : '';
|
|
||||||
if (drawn === this.pictureOf) return;
|
|
||||||
this.pictureOf = drawn;
|
|
||||||
this.picture.clear();
|
|
||||||
if (!picture || !box) return;
|
|
||||||
const c = Theme.colors;
|
|
||||||
this.picture.position.set(box.x, box.y);
|
|
||||||
this.picture.rect(0, 0, box.w, box.h).fill(c.graphBackground).stroke({ color: c.nodeBorder, width: 1 });
|
|
||||||
for (const { kind, points } of picture.shapes(this.node.params, box.w)) {
|
|
||||||
if (kind === 'area') {
|
|
||||||
this.picture.poly(points).fill({ color: c.accent, alpha: 0.18 });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.picture.moveTo(points[0]!, points[1]!);
|
|
||||||
for (let i = 2; i < points.length; i += 2) this.picture.lineTo(points[i]!, points[i + 1]!);
|
|
||||||
this.picture.stroke(
|
|
||||||
kind === 'guide' ? { color: c.textMuted, width: 1, alpha: 0.5 } : { color: c.accent, width: 1.5 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local position of a port's centre. */
|
/** Local position of a port's centre. */
|
||||||
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||||||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||||||
@@ -605,7 +563,6 @@ export class NodeView extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.drawPicture();
|
|
||||||
const drawn = this.drawSignature();
|
const drawn = this.drawSignature();
|
||||||
if (drawn === this.drawnOf) return;
|
if (drawn === this.drawnOf) return;
|
||||||
this.drawnOf = drawn;
|
this.drawnOf = drawn;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
COMPRESSOR_ROWS,
|
COMPRESSOR_ROWS,
|
||||||
type CompressorKey,
|
type CompressorKey,
|
||||||
type CompressorNumbers,
|
type CompressorNumbers,
|
||||||
|
ratioText,
|
||||||
} from './compressorView';
|
} from './compressorView';
|
||||||
import {
|
import {
|
||||||
type Box,
|
type Box,
|
||||||
@@ -344,6 +345,8 @@ export class CompressorPanel extends Widget {
|
|||||||
size: PANEL_LAYOUT.knobs[key].size,
|
size: PANEL_LAYOUT.knobs[key].size,
|
||||||
look: 'chrome',
|
look: 'chrome',
|
||||||
typed: false,
|
typed: false,
|
||||||
|
format: key === 'ratio' ? ratioText : undefined,
|
||||||
|
ink: INK,
|
||||||
onInput: (value) => host.preview(key, value),
|
onInput: (value) => host.preview(key, value),
|
||||||
onChange: (value) => host.commit(key, value),
|
onChange: (value) => host.commit(key, value),
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-593
@@ -1,648 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the
|
* The tab of a Parametric EQ node (#370): the panel of `EqPanel.ts` under a header, pointed at the
|
||||||
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
* node whose tab is showing. While the tab is seen, the spectrum of what passes the node is drawn
|
||||||
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
* behind the curve (#374).
|
||||||
* undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376). While
|
|
||||||
* the tab 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 { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
|
||||||
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,
|
|
||||||
withBandSet,
|
|
||||||
xToFrequency,
|
|
||||||
yToDb,
|
|
||||||
} from '../../core/soundfx/parametricEq';
|
|
||||||
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
|
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import type { Spectrum } from '../../viewport/spectrum';
|
import { Label, Spacer, VStack } from '../../ui';
|
||||||
import {
|
|
||||||
crispText,
|
|
||||||
Dropdown,
|
|
||||||
HStack,
|
|
||||||
Label,
|
|
||||||
Menu,
|
|
||||||
NumberField,
|
|
||||||
pressCount,
|
|
||||||
Spacer,
|
|
||||||
Theme,
|
|
||||||
textStyle,
|
|
||||||
VStack,
|
|
||||||
Widget,
|
|
||||||
} from '../../ui';
|
|
||||||
import { FramePreview } from '../framePreview';
|
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
import { ViewTween } from '../viewTween';
|
import { EqPanel } from './EqPanel';
|
||||||
import {
|
|
||||||
bandAt,
|
|
||||||
EQ_GRID_FREQUENCIES,
|
|
||||||
eqGridDb,
|
|
||||||
handleOf,
|
|
||||||
SPECTRUM_FLOOR_DB,
|
|
||||||
spectrumPeak,
|
|
||||||
spectrumYs,
|
|
||||||
wheeledQ,
|
|
||||||
wheeledRange,
|
|
||||||
} from './eqView';
|
|
||||||
|
|
||||||
/** The least the curve shrinks to; otherwise it takes the tab. */
|
/** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */
|
||||||
const MIN_CURVE_WIDTH = 320;
|
export class EqEditor extends VStack {
|
||||||
const MIN_CURVE_HEIGHT = 160;
|
readonly panel: EqPanel;
|
||||||
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;
|
|
||||||
|
|
||||||
/** What the curve asks of the tab 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;
|
|
||||||
cancel(): 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 = 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 turns its Q, anywhere else it zooms the dB the curve shows (#376). */
|
|
||||||
private wheel(e: FederatedWheelEvent): void {
|
|
||||||
if (e.deltaY === 0) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
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];
|
|
||||||
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 tab of a Parametric EQ node (#370): one editor, pointed at the node whose tab is showing. It
|
|
||||||
* follows its node; once the node is gone the editor tells the tab row through `onGone`.
|
|
||||||
*/
|
|
||||||
export class EqEditor 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 title = new Label('Parametric EQ', { style: 'bodyBold' });
|
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
|
||||||
private readonly count = new Label('', { style: 'muted' });
|
|
||||||
private readonly header = new TabHeader();
|
private readonly header = new TabHeader();
|
||||||
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
|
/** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */
|
||||||
private readonly bandLabel = new Label('', { style: 'small' });
|
private listening = false;
|
||||||
readonly typeButton: Dropdown;
|
|
||||||
readonly slopeButton: Dropdown;
|
|
||||||
readonly frequencyField: NumberField;
|
|
||||||
readonly gainField: NumberField;
|
|
||||||
readonly qField: NumberField;
|
|
||||||
private readonly perFrame: FramePreview;
|
|
||||||
/** The texts the tab was last laid out for; a drag changes only numbers and needs no layout. */
|
|
||||||
private laidOut = '';
|
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.perFrame = new FramePreview(state, 'eq-band');
|
this.panel = new EqPanel(state);
|
||||||
this.curve = new EqCurve(this);
|
|
||||||
this.header.add(this.title);
|
this.header.add(this.title);
|
||||||
this.header.add(this.count);
|
|
||||||
this.header.add(new Spacer(), 1);
|
this.header.add(new Spacer(), 1);
|
||||||
|
|
||||||
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 })),
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.body.add(this.curve, 1);
|
|
||||||
this.body.add(row);
|
|
||||||
this.body.add(
|
|
||||||
new Label(
|
|
||||||
'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
|
|
||||||
{ style: 'muted' },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.add(this.header);
|
this.add(this.header);
|
||||||
this.add(this.body, 1);
|
this.add(this.panel, 1);
|
||||||
state.on('project', () => this.sync());
|
state.on('project', () => this.sync());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Points the tab at a node; the band picked stays when it is the same node again. */
|
|
||||||
show(assetId: string, nodeId: string): void {
|
show(assetId: string, nodeId: string): void {
|
||||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
const shown = this.panel.shownFor;
|
||||||
this.curve.settle();
|
if (shown && (shown.assetId !== assetId || shown.nodeId !== nodeId)) this.hideSpectrum();
|
||||||
this.perFrame.cancel();
|
this.panel.show(assetId, nodeId);
|
||||||
this.hideSpectrum();
|
|
||||||
this.before = null;
|
|
||||||
this.assetId = assetId;
|
|
||||||
this.nodeId = nodeId;
|
|
||||||
this.picked = this.bands().length > 0 ? 0 : -1;
|
|
||||||
}
|
|
||||||
this.sync();
|
this.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tab is closed: the editor lets go of its node. */
|
/** The tab is closed: the editor lets go of its node. */
|
||||||
close(): void {
|
close(): void {
|
||||||
this.perFrame.cancel();
|
|
||||||
this.hideSpectrum();
|
this.hideSpectrum();
|
||||||
const node = this.node();
|
this.panel.close();
|
||||||
if (node) this.restore(node);
|
|
||||||
this.assetId = '';
|
|
||||||
this.nodeId = '';
|
|
||||||
this.picked = -1;
|
|
||||||
this.laidOut = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get shownFor(): { assetId: string; nodeId: string } | null {
|
get shownFor(): { assetId: string; nodeId: string } | null {
|
||||||
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
return this.panel.shownFor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
|
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
|
||||||
tick(): void {
|
tick(): void {
|
||||||
if (!this.nodeId) return;
|
const shown = this.panel.shownFor;
|
||||||
const id = tapId(this.assetId, this.nodeId);
|
if (!shown) return;
|
||||||
|
const id = tapId(shown.assetId, shown.nodeId);
|
||||||
soundLibrary.listen(id);
|
soundLibrary.listen(id);
|
||||||
this.curve.showSpectrum(soundLibrary.spectrum(id));
|
this.listening = true;
|
||||||
|
this.panel.curve.showSpectrum(soundLibrary.spectrum(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tab is not seen: no analyser is fed for it. */
|
/** The tab is not seen: it lets go of the tap once, so the node in the graph keeps it. */
|
||||||
hideSpectrum(): void {
|
hideSpectrum(): void {
|
||||||
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
const shown = this.panel.shownFor;
|
||||||
this.curve.showSpectrum(null);
|
if (this.listening && shown) soundLibrary.unlisten(tapId(shown.assetId, shown.nodeId));
|
||||||
|
this.listening = false;
|
||||||
|
this.panel.curve.showSpectrum(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the spectrum is moving, so the tab has to be drawn every frame. */
|
/** Whether the spectrum is moving, so the tab has to be drawn every frame. */
|
||||||
get hasSpectrum(): boolean {
|
get hasSpectrum(): boolean {
|
||||||
return this.curve.hasSpectrum;
|
return this.panel.curve.hasSpectrum;
|
||||||
}
|
|
||||||
|
|
||||||
private node(): GraphNode | null {
|
|
||||||
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
bands(): EqBand[] {
|
|
||||||
const node = this.node();
|
|
||||||
return node ? eqBandsOf(node.params) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete in the tab takes the picked band – never the node the tab is about. */
|
/** Delete in the tab takes the picked band – never the node the tab is about. */
|
||||||
removePicked(): void {
|
removePicked(): void {
|
||||||
const index = this.picked;
|
this.panel.removePicked();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sync(): void {
|
private sync(): void {
|
||||||
const node = this.node();
|
const shown = this.panel.shownFor;
|
||||||
if (!node) return;
|
if (!shown) return;
|
||||||
const bands = eqBandsOf(node.params);
|
this.panel.sync();
|
||||||
if (this.picked >= bands.length) this.picked = -1;
|
const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
|
||||||
const band = bands[this.picked];
|
if (title === this.title.text) return;
|
||||||
const title = `Parametric EQ – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
|
|
||||||
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
|
|
||||||
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
|
|
||||||
this.title.setText(title);
|
this.title.setText(title);
|
||||||
this.count.setText(count);
|
this.relayout();
|
||||||
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 = [title, count, bandLabel].join('\n');
|
|
||||||
if (texts !== this.laidOut) {
|
|
||||||
this.laidOut = texts;
|
|
||||||
this.relayout();
|
|
||||||
}
|
|
||||||
this.curve.invalidate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Parametric EQ node a tab is about, or null once it, or its asset, is gone. */
|
|
||||||
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
|
|
||||||
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
|
|
||||||
return node?.type === SoundFxParametricEq.type ? node : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 tab says: its asset's name, and which EQ it is when the asset has more than one.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class EqFace implements NodeFace {
|
||||||
|
readonly widget: EqPanel;
|
||||||
|
private listening = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
state: EditorState,
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
) {
|
||||||
|
this.widget = new EqPanel(state, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(): void {
|
||||||
|
this.widget.show(this.assetId, this.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(): boolean {
|
||||||
|
if (!this.widget.shownFor) return false;
|
||||||
|
const id = tapId(this.assetId, this.nodeId);
|
||||||
|
soundLibrary.listen(id);
|
||||||
|
this.listening = true;
|
||||||
|
this.widget.curve.showSpectrum(soundLibrary.spectrum(id));
|
||||||
|
return this.widget.curve.hasSpectrum;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
if (!this.listening) return;
|
||||||
|
this.listening = false;
|
||||||
|
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
||||||
|
this.widget.curve.showSpectrum(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
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 type { EditorState } from '../state';
|
||||||
|
import { ViewTween } from '../viewTween';
|
||||||
|
import {
|
||||||
|
bandAt,
|
||||||
|
EQ_GRID_FREQUENCIES,
|
||||||
|
eqGridDb,
|
||||||
|
handleOf,
|
||||||
|
SPECTRUM_FLOOR_DB,
|
||||||
|
spectrumPeak,
|
||||||
|
spectrumYs,
|
||||||
|
wheeledQ,
|
||||||
|
wheeledRange,
|
||||||
|
} from './eqView';
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
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' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bands(): EqBand[] {
|
||||||
|
const node = this.node();
|
||||||
|
return node ? eqBandsOf(node.params) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Parametric EQ node a panel is about, or null once it, or its asset, is gone. */
|
||||||
|
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
|
||||||
|
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
|
||||||
|
return node?.type === SoundFxParametricEq.type ? node : null;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
|
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
|
||||||
import type { PropertyBag } from '../../core/model/Properties';
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
|
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
|
||||||
import { clamp, snapToStep } from '../../ui/math';
|
import { clamp, formatNumber, snapToStep } from '../../ui/math';
|
||||||
import type { Levels } from '../../viewport/spectrum';
|
import type { Levels } from '../../viewport/spectrum';
|
||||||
|
|
||||||
/** The dB the level picture spans, bottom to top. */
|
/** The dB the level picture spans, bottom to top. */
|
||||||
@@ -41,6 +41,10 @@ export const COMPRESSOR_KNOBS: Record<CompressorKey, { label: string; taper: num
|
|||||||
makeup: { label: 'Makeup dB', taper: 1, decimals: 1 },
|
makeup: { label: 'Makeup dB', taper: 1, decimals: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A ratio as a device prints it: `4:1` (#382). */
|
||||||
|
export const ratioText = (ratio: number): string =>
|
||||||
|
`${formatNumber(ratio, COMPRESSOR_KNOBS.ratio.decimals)}:1`;
|
||||||
|
|
||||||
export interface CompressorNumbers {
|
export interface CompressorNumbers {
|
||||||
threshold: number;
|
threshold: number;
|
||||||
knee: number;
|
knee: number;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { GraphNode } from '../../core/graph';
|
import type { GraphNode } from '../../core/graph';
|
||||||
import { SoundFxCompressor } from '../../core/soundfx/nodes';
|
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import type { NodeFace } from '../graph/nodeFace';
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { CompressorFace } from './CompressorFace';
|
import { CompressorFace } from './CompressorFace';
|
||||||
|
import { EqFace } from './EqFace';
|
||||||
|
|
||||||
/** The nodes drawn as a face of their own instead of rows (#380), by type. */
|
/** The nodes drawn as a face of their own instead of rows (#380, #381), by type. */
|
||||||
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
||||||
return node.type === SoundFxCompressor.type ? new CompressorFace(state, assetId, node.id) : 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);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,8 @@ import type { GraphNode } from '../../core/graph';
|
|||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
|
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
|
||||||
import { eqNodeOf, eqTabLabel } from './EqEditor';
|
import { eqTabLabel } from './EqEditor';
|
||||||
|
import { eqNodeOf } from './EqPanel';
|
||||||
|
|
||||||
export type NodeTabKind = 'eq' | 'compressor';
|
export type NodeTabKind = 'eq' | 'compressor';
|
||||||
|
|
||||||
|
|||||||
+54
-17
@@ -10,6 +10,7 @@ import {
|
|||||||
wheeledValue,
|
wheeledValue,
|
||||||
} from '../knob';
|
} from '../knob';
|
||||||
import { VStack } from '../layout/Stack';
|
import { VStack } from '../layout/Stack';
|
||||||
|
import { clamp, formatNumber, roundTo } from '../math';
|
||||||
import { Theme } from '../Theme';
|
import { Theme } from '../Theme';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
@@ -31,8 +32,15 @@ export interface KnobOptions extends KnobRange {
|
|||||||
size?: number;
|
size?: number;
|
||||||
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
|
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
|
||||||
look?: 'flat' | 'chrome';
|
look?: 'flat' | 'chrome';
|
||||||
/** Whether a click on the number types one; a knob on a node (#380) only shows it. */
|
/**
|
||||||
|
* Whether a click on the number types one. A knob on a node (#380) only shows it, as a centred
|
||||||
|
* label without a box (#382).
|
||||||
|
*/
|
||||||
typed?: boolean;
|
typed?: boolean;
|
||||||
|
/** How a shown number reads, `4:1` for a ratio (#382); typed numbers keep their plain digits. */
|
||||||
|
format?: (value: number) => string;
|
||||||
|
/** The shown number's colour, to stand on a panel's own plate. */
|
||||||
|
ink?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
||||||
@@ -178,13 +186,28 @@ class Dial extends Widget {
|
|||||||
/** The value's arc on a chrome dial: the orange of a lit scale. */
|
/** The value's arc on a chrome dial: the orange of a lit scale. */
|
||||||
const CHROME_ARC = 0xff8a1e;
|
const CHROME_ARC = 0xff8a1e;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share of a dial's size left empty at its bottom: the scale ends 45° below the level, at
|
||||||
|
* 0.71 of the radius, the box at 1. A little of it stays as air above the number.
|
||||||
|
*/
|
||||||
|
const OPEN_BOTTOM = 0.12;
|
||||||
|
|
||||||
|
/** A number that only shows: as high as its text, not a whole row, so it hugs the dial (#382). */
|
||||||
|
function tightLabel(text: string, ink: number | undefined): Label {
|
||||||
|
const label = new Label(text, { align: 'center', color: ink });
|
||||||
|
label.minWidth = 56;
|
||||||
|
label.minHeight = 0;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
|
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
|
||||||
* below, which takes a typed value on a click.
|
* below, which takes a typed value on a click.
|
||||||
*/
|
*/
|
||||||
export class Knob extends VStack {
|
export class Knob extends VStack {
|
||||||
readonly dial: Dial;
|
readonly dial: Dial;
|
||||||
readonly field: NumberField;
|
/** The number below: a field to type into, or with `typed: false` a label that only shows it. */
|
||||||
|
readonly field: NumberField | Label;
|
||||||
private readonly caption: Label;
|
private readonly caption: Label;
|
||||||
private current: number;
|
private current: number;
|
||||||
|
|
||||||
@@ -193,20 +216,24 @@ export class Knob extends VStack {
|
|||||||
value: number,
|
value: number,
|
||||||
readonly options: KnobOptions,
|
readonly options: KnobOptions,
|
||||||
) {
|
) {
|
||||||
super({ gap: 2, align: 'center' });
|
// A shown number without a name sits up in the dial's open bottom, below the ends of its scale (#382).
|
||||||
|
const tucked = options.typed === false && !label;
|
||||||
|
super({ gap: tucked ? -Math.round((options.size ?? DIAL_SIZE) * OPEN_BOTTOM) : 2, align: 'center' });
|
||||||
this.current = value;
|
this.current = value;
|
||||||
this.caption = new Label(label, { style: 'small' });
|
this.caption = new Label(label, { style: 'small' });
|
||||||
this.dial = new Dial(this);
|
this.dial = new Dial(this);
|
||||||
this.field = new NumberField(value, {
|
this.field =
|
||||||
width: 56,
|
options.typed === false
|
||||||
min: options.min,
|
? tightLabel(this.text(value), options.ink)
|
||||||
max: options.max,
|
: new NumberField(value, {
|
||||||
step: options.step,
|
width: 56,
|
||||||
decimals: options.decimals,
|
min: options.min,
|
||||||
onInput: (typed) => this.preview(typed),
|
max: options.max,
|
||||||
onChange: (typed) => this.commit(typed),
|
step: options.step,
|
||||||
});
|
decimals: options.decimals,
|
||||||
if (options.typed === false) this.field.eventMode = 'none';
|
onInput: (typed) => this.preview(typed),
|
||||||
|
onChange: (typed) => this.commit(typed),
|
||||||
|
});
|
||||||
// Without a name the knob has none above it: a panel engraves its own.
|
// Without a name the knob has none above it: a panel engraves its own.
|
||||||
if (label) this.add(this.caption);
|
if (label) this.add(this.caption);
|
||||||
this.add(this.dial);
|
this.add(this.dial);
|
||||||
@@ -217,12 +244,15 @@ export class Knob extends VStack {
|
|||||||
return this.current;
|
return this.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The number as it reads below the dial. */
|
||||||
|
get shown(): string {
|
||||||
|
return this.field instanceof NumberField ? this.field.value : this.field.text;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shows a value from outside; fires nothing. A drag going on keeps its own. */
|
/** Shows a value from outside; fires nothing. A drag going on keeps its own. */
|
||||||
setValue(value: number): void {
|
setValue(value: number): void {
|
||||||
if (this.dial.dragging || value === this.current) return;
|
if (this.dial.dragging || value === this.current) return;
|
||||||
this.current = value;
|
this.show(value);
|
||||||
this.field.setNumber(value);
|
|
||||||
this.dial.invalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
preview(value: number): void {
|
preview(value: number): void {
|
||||||
@@ -237,10 +267,17 @@ export class Knob extends VStack {
|
|||||||
|
|
||||||
private show(value: number): void {
|
private show(value: number): void {
|
||||||
this.current = value;
|
this.current = value;
|
||||||
this.field.setNumber(value);
|
if (this.field instanceof NumberField) this.field.setNumber(value);
|
||||||
|
else this.field.setText(this.text(value));
|
||||||
this.dial.invalidate();
|
this.dial.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A shown number: rounded to its decimals and clamped like a typed one, then formatted. */
|
||||||
|
private text(value: number): string {
|
||||||
|
const shown = clamp(roundTo(value, this.options.decimals), this.options.min, this.options.max);
|
||||||
|
return this.options.format?.(shown) ?? formatNumber(shown, this.options.decimals);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where the dial is on screen, for the check scripts. */
|
/** Where the dial is on screen, for the check scripts. */
|
||||||
dialGlobal(): { x: number; y: number } {
|
dialGlobal(): { x: number; y: number } {
|
||||||
const at = this.dial.getGlobalPosition();
|
const at = this.dial.getGlobalPosition();
|
||||||
|
|||||||
+11
-4
@@ -15,6 +15,8 @@ export interface LabelOptions {
|
|||||||
* asks for no width of its own, and its height follows `heightFor`.
|
* asks for no width of its own, and its height follows `heightFor`.
|
||||||
*/
|
*/
|
||||||
wrap?: boolean;
|
wrap?: boolean;
|
||||||
|
/** The text's own colour instead of the style's – a label on a surface of its own (#382). */
|
||||||
|
color?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Text that truncates with an ellipsis when narrower than its content, or wraps with `wrap`. */
|
/** Text that truncates with an ellipsis when narrower than its content, or wraps with `wrap`. */
|
||||||
@@ -25,6 +27,7 @@ export class Label extends Widget {
|
|||||||
private readonly padding: number;
|
private readonly padding: number;
|
||||||
private readonly fixedWidth: number;
|
private readonly fixedWidth: number;
|
||||||
private readonly wrap: boolean;
|
private readonly wrap: boolean;
|
||||||
|
private readonly color: number | undefined;
|
||||||
private underline: Graphics | null = null;
|
private underline: Graphics | null = null;
|
||||||
|
|
||||||
constructor(text = '', options: LabelOptions = {}) {
|
constructor(text = '', options: LabelOptions = {}) {
|
||||||
@@ -35,18 +38,22 @@ export class Label extends Widget {
|
|||||||
this.padding = options.padding ?? 0;
|
this.padding = options.padding ?? 0;
|
||||||
this.fixedWidth = options.width ?? 0;
|
this.fixedWidth = options.width ?? 0;
|
||||||
this.wrap = options.wrap ?? false;
|
this.wrap = options.wrap ?? false;
|
||||||
|
this.color = options.color;
|
||||||
this.minWidth = this.fixedWidth;
|
this.minWidth = this.fixedWidth;
|
||||||
this.textNode = crispText({ text, style: this.styleFor(options.style ?? 'body') });
|
this.textNode = crispText({ text, style: this.styleFor(options.style ?? 'body') });
|
||||||
this.addChild(this.textNode);
|
this.addChild(this.textNode);
|
||||||
this.minHeight = Theme.rowHeight;
|
this.minHeight = Theme.rowHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shared style, or an own copy for a wrapping label: it carries its wrap width. */
|
/** The shared style, or an own copy for a wrapping label (its wrap width) or one of its own colour. */
|
||||||
private styleFor(token: TextStyleToken): TextStyle {
|
private styleFor(token: TextStyleToken): TextStyle {
|
||||||
if (!this.wrap) return textStyle(token);
|
if (!this.wrap && this.color === undefined) return textStyle(token);
|
||||||
const style = textStyle(token).clone();
|
const style = textStyle(token).clone();
|
||||||
style.wordWrap = true;
|
if (this.color !== undefined) style.fill = this.color;
|
||||||
style.breakWords = true;
|
if (this.wrap) {
|
||||||
|
style.wordWrap = true;
|
||||||
|
style.breakWords = true;
|
||||||
|
}
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ export class Popup extends Widget {
|
|||||||
|
|
||||||
/** Opens left-aligned under `anchor`, and never narrower than it. */
|
/** Opens left-aligned under `anchor`, and never narrower than it. */
|
||||||
openBelow(anchor: Widget, gap = 2): void {
|
openBelow(anchor: Widget, gap = 2): void {
|
||||||
const g = anchor.getGlobalPosition();
|
// Below the anchor as drawn: one in a zoomed graph is not `h` pixels tall on screen.
|
||||||
|
const g = anchor.toGlobal({ x: 0, y: anchor.h });
|
||||||
this.minWidth = Math.max(this.minWidth, anchor.w);
|
this.minWidth = Math.max(this.minWidth, anchor.w);
|
||||||
this.open(g.x, g.y + anchor.h + gap);
|
this.open(g.x, g.y + gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
openCentered(): void {
|
openCentered(): void {
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, the curve the
|
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, the curve the
|
||||||
* editor draws from them, the curve in the node (#375), and the taps the spectrum is heard at (#374).
|
* editor draws from them, and the taps the spectrum is heard at (#374).
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||||||
import { fxChainOf, SoundFxParametricEq, tapId } from '../../src/core/soundfx/nodes';
|
import { fxChainOf, tapId } from '../../src/core/soundfx/nodes';
|
||||||
import {
|
import {
|
||||||
dbToY,
|
dbToY,
|
||||||
type EqBand,
|
type EqBand,
|
||||||
eqBandResponse,
|
eqBandResponse,
|
||||||
eqBandsOf,
|
eqBandsOf,
|
||||||
|
eqCurvePoints,
|
||||||
eqCurves,
|
eqCurves,
|
||||||
|
eqRangeOf,
|
||||||
fillShapes,
|
fillShapes,
|
||||||
frequencyToX,
|
frequencyToX,
|
||||||
MAX_EQ_BANDS,
|
MAX_EQ_BANDS,
|
||||||
@@ -151,45 +153,40 @@ describe('the bands of a Parametric EQ', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the curve in the node (#375)', () => {
|
describe('the curve as the panel draws it', () => {
|
||||||
const picture = SoundFxParametricEq.picture!;
|
|
||||||
const W = 164;
|
const W = 164;
|
||||||
const area = { w: W, h: picture.height, range: 24 };
|
const H = 60;
|
||||||
const lineOf = (bands: unknown, range?: number) =>
|
const area = { w: W, h: H, range: 24 };
|
||||||
picture.shapes({ bands, range } as never, W).find((s) => s.kind === 'line')!;
|
const topOf = (ys: number[]) => Math.min(...ys);
|
||||||
const topOf = (line: { points: number[] }) => Math.min(...line.points.filter((_, i) => i % 2 === 1));
|
|
||||||
|
|
||||||
it('has its highest point where the peak is, as high as its gain', () => {
|
it('has its highest point where the peak is, as high as its gain', () => {
|
||||||
const line = lineOf([band({ gain: 6 })]);
|
const { xs, sum } = eqCurvePoints([band({ gain: 6 })], area, 96);
|
||||||
let top = 1;
|
const top = sum.indexOf(topOf(sum));
|
||||||
for (let i = 3; i < line.points.length; i += 2) if (line.points[i]! < line.points[top]!) top = i;
|
expect(Math.abs(xs[top]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
|
||||||
expect(Math.abs(line.points[top - 1]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
|
expect(sum[top]).toBeCloseTo(dbToY(6, area), 1);
|
||||||
expect(line.points[top]).toBeCloseTo(dbToY(6, area), 1);
|
expect(xs.length).toBe(97);
|
||||||
expect(line.points.length).toBe(97 * 2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps its box without a band, flat on the 0 dB line', () => {
|
it('lies flat on the 0 dB line without a band', () => {
|
||||||
const shapes = picture.shapes({}, W);
|
const { sum } = eqCurvePoints([], area, 96);
|
||||||
const zero = dbToY(0, area);
|
expect(sum.every((y) => y === dbToY(0, area))).toBe(true);
|
||||||
expect(picture.height).toBe(60);
|
|
||||||
expect(shapes[0]).toEqual({ kind: 'guide', points: [0, zero, W, zero] });
|
|
||||||
const line = shapes.find((s) => s.kind === 'line')!;
|
|
||||||
expect(line.points.filter((_, i) => i % 2 === 1).every((y) => y === zero)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays inside its box however deep a cut goes', () => {
|
it('stays inside its box however deep a cut goes, or as far past it as it is let', () => {
|
||||||
const line = lineOf([band({ type: 'highpass', frequency: 2000, slope: 48 })]);
|
const cut = [band({ type: 'highpass', frequency: 2000, slope: 48 })];
|
||||||
const ys = line.points.filter((_, i) => i % 2 === 1);
|
const { sum } = eqCurvePoints(cut, area, 96);
|
||||||
expect(Math.max(...ys)).toBe(picture.height);
|
expect(Math.max(...sum)).toBe(H);
|
||||||
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
|
expect(Math.min(...sum)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Math.max(...eqCurvePoints(cut, area, 96, 2).sum)).toBe(H + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the dB its tab is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => {
|
it('shows the dB the node is zoomed to, and ±24 dB for a range the tab does not offer (#376)', () => {
|
||||||
const peak = [band({ gain: 6 })];
|
const peak = [band({ gain: 6 })];
|
||||||
expect(topOf(lineOf(peak))).toBeCloseTo(dbToY(6, area), 1);
|
const at = (params: object) =>
|
||||||
expect(topOf(lineOf(peak, 12))).toBeCloseTo(dbToY(6, { ...area, range: 12 }), 1);
|
topOf(eqCurvePoints(peak, { ...area, range: eqRangeOf(params as never) }, 96).sum);
|
||||||
expect(topOf(lineOf(peak, 12))).toBeCloseTo(picture.height / 4, 1);
|
expect(at({})).toBeCloseTo(dbToY(6, area), 1);
|
||||||
expect(lineOf(peak, 7)).toEqual(lineOf(peak));
|
expect(at({ range: 12 })).toBeCloseTo(H / 4, 1);
|
||||||
|
expect(at({ range: 7 })).toBe(at({}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
picturesLayout,
|
picturesLayout,
|
||||||
ratioFor,
|
ratioFor,
|
||||||
ratioHandle,
|
ratioHandle,
|
||||||
|
ratioText,
|
||||||
reductionOf,
|
reductionOf,
|
||||||
reductionShapes,
|
reductionShapes,
|
||||||
thresholdHandle,
|
thresholdHandle,
|
||||||
@@ -95,6 +96,11 @@ describe('the numbers of a Compressor node (#380)', () => {
|
|||||||
expect(autoGainText({ ...numbers, autoGain: false })).toBe('off');
|
expect(autoGainText({ ...numbers, autoGain: false })).toBe('off');
|
||||||
expect(autoGainText({ ...numbers, autoGain: true })).toMatch(/^\+\d+\.\d dB$/);
|
expect(autoGainText({ ...numbers, autoGain: true })).toMatch(/^\+\d+\.\d dB$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prints the ratio as a device does, without a trailing zero (#382)', () => {
|
||||||
|
expect(ratioText(4)).toBe('4:1');
|
||||||
|
expect(ratioText(2.5)).toBe('2.5:1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the level picture of a Compressor', () => {
|
describe('the level picture of a Compressor', () => {
|
||||||
|
|||||||
+82
-6
@@ -647,7 +647,8 @@ export async function sound(t) {
|
|||||||
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
|
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
|
||||||
await frame();
|
await frame();
|
||||||
const seen = __gm.eq.spectrum().taps;
|
const seen = __gm.eq.spectrum().taps;
|
||||||
__gm.openDocument(${JSON.stringify(`asset:${fxId}`)});
|
// Not the sound FX graph: the node there listens to the same tap (#381).
|
||||||
|
__gm.showTab('scene');
|
||||||
await frame();
|
await frame();
|
||||||
await frame();
|
await frame();
|
||||||
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
|
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
|
||||||
@@ -663,6 +664,76 @@ export async function sound(t) {
|
|||||||
JSON.stringify(unseen),
|
JSON.stringify(unseen),
|
||||||
);
|
);
|
||||||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||||
|
// #381: the EQ node shows the panel of its tab at full size, its inputs on the left and its output
|
||||||
|
// on the right. A double-click on its curve adds a band without opening the tab, a drag is one
|
||||||
|
// undo step, the wheel over a band zooms the graph and leaves its Q and the dB alone (#383),
|
||||||
|
// and a right-click takes the band. Played, the node
|
||||||
|
// draws the spectrum, its tab open in the row.
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const eqPanel = () => json("__gm.eq.panel('fx-eq')");
|
||||||
|
const eqFace = await eqPanel();
|
||||||
|
const eqRect = eqFace.rect;
|
||||||
|
const besideEq = (p) => p.y > eqRect.y && p.y < eqRect.y + eqRect.h;
|
||||||
|
check(
|
||||||
|
'a Parametric EQ node shows the panel of its tab at full size, its inputs on the left of it and its output on the right',
|
||||||
|
eqFace.shown &&
|
||||||
|
eqFace.size.w === 720 &&
|
||||||
|
eqFace.size.h === 360 &&
|
||||||
|
Object.values(eqFace.inputs).length >= 1 &&
|
||||||
|
Object.values(eqFace.inputs).every((p) => p.x < eqRect.x && besideEq(p)) &&
|
||||||
|
Object.values(eqFace.outputs).length === 1 &&
|
||||||
|
Object.values(eqFace.outputs).every((p) => p.x > eqRect.x + eqRect.w && besideEq(p)) &&
|
||||||
|
eqFace.curve.h > eqRect.h / 2 &&
|
||||||
|
eqFace.bands.length === 1,
|
||||||
|
JSON.stringify(eqFace),
|
||||||
|
);
|
||||||
|
const eqNodeAt = (hz, db) => json(`__gm.eq.panelPointAt('fx-eq', ${hz}, ${db})`);
|
||||||
|
const eqAddAt = await eqNodeAt(250, 6);
|
||||||
|
await t.doubleClick(eqAddAt.x, eqAddAt.y);
|
||||||
|
const eqNodeAdded = { panel: await eqPanel(), docs: await json('__gm.documents()') };
|
||||||
|
const eqNodeTo = await eqNodeAt(500, -3);
|
||||||
|
const eqNodeHandle = eqNodeAdded.panel.handles[1];
|
||||||
|
await drag(eqNodeHandle.x, eqNodeHandle.y, eqNodeTo.x, eqNodeTo.y);
|
||||||
|
const eqNodeDragged = (await eqPanel()).bands[1];
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const eqNodeUndone = (await eqPanel()).bands[1];
|
||||||
|
const eqRangeBefore = await json('__gm.eq.range()');
|
||||||
|
await t.wheel(eqNodeHandle.x, eqNodeHandle.y, -100);
|
||||||
|
const eqNodeWheeled = { panel: await eqPanel(), range: await json('__gm.eq.range()') };
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const eqNodeLast = (await eqPanel()).handles[1];
|
||||||
|
await click(eqNodeLast.x, eqNodeLast.y, 'right');
|
||||||
|
const eqNodeRemoved = await eqPanel();
|
||||||
|
check(
|
||||||
|
'in the node, a double-click on the curve adds a band without opening the tab, a drag moves it as one undo step, the wheel over it zooms the graph and leaves its Q, and a right-click takes it',
|
||||||
|
eqNodeAdded.panel.bands.length === 2 &&
|
||||||
|
bandNear(eqNodeAdded.panel.bands[1], 250, 6) &&
|
||||||
|
eqNodeAdded.panel.picked === 1 &&
|
||||||
|
eqNodeAdded.docs.active === `asset:${fxId}` &&
|
||||||
|
bandNear(eqNodeDragged, 500, -3) &&
|
||||||
|
bandNear(eqNodeUndone, 250, 6) &&
|
||||||
|
eqNodeWheeled.panel.bands[1].q === eqNodeUndone.q &&
|
||||||
|
eqNodeWheeled.panel.rect.w > eqRect.w + 10 &&
|
||||||
|
eqNodeWheeled.range === eqRangeBefore &&
|
||||||
|
eqNodeRemoved.bands.length === 1 &&
|
||||||
|
bandNear(eqNodeRemoved.bands[0], 4000, -6),
|
||||||
|
JSON.stringify({ eqNodeAdded, eqNodeDragged, eqNodeUndone, eqNodeWheeled, eqNodeRemoved }),
|
||||||
|
);
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
const eqNodeFrames = (await eqPanel()).spectrum.frames;
|
||||||
|
await evaluate('__gm.soundfx.play()');
|
||||||
|
const eqNodeHeard = await waitFor(eqPanel, (p) => p.spectrum.frames >= eqNodeFrames + 3);
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
check(
|
||||||
|
'played, the EQ node draws the blip’s 880 Hz behind its curve, its tab open in the row',
|
||||||
|
eqNodeHeard.spectrum.frames >= eqNodeFrames + 3 &&
|
||||||
|
nearBlip(eqNodeHeard.spectrum.peakOut) &&
|
||||||
|
(await json('__gm.documents()')).docs.includes(`eq:${fxId}:fx-eq`),
|
||||||
|
JSON.stringify(eqNodeHeard.spectrum),
|
||||||
|
);
|
||||||
await shot('34-eq-node');
|
await shot('34-eq-node');
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
const eqBefore = await stats();
|
const eqBefore = await stats();
|
||||||
@@ -875,8 +946,10 @@ export async function sound(t) {
|
|||||||
const hwOutputs = Object.values(hwShown.outputs);
|
const hwOutputs = Object.values(hwShown.outputs);
|
||||||
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
|
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
|
||||||
check(
|
check(
|
||||||
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right',
|
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right, its numbers as plain labels, the ratio as 8:1',
|
||||||
hwShown.shown &&
|
hwShown.shown &&
|
||||||
|
hwShown.numbers.ratio === '8:1' &&
|
||||||
|
hwShown.numbers.threshold === '-50' &&
|
||||||
hwShown.size.w === 820 &&
|
hwShown.size.w === 820 &&
|
||||||
hwShown.size.h === 280 &&
|
hwShown.size.h === 280 &&
|
||||||
hwInputs.length === 8 &&
|
hwInputs.length === 8 &&
|
||||||
@@ -899,17 +972,20 @@ export async function sound(t) {
|
|||||||
const hwRatio = hwShown.dials.ratio;
|
const hwRatio = hwShown.dials.ratio;
|
||||||
await t.wheel(hwRatio.x, hwRatio.y, -100);
|
await t.wheel(hwRatio.x, hwRatio.y, -100);
|
||||||
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
|
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||||||
await t.doubleClick(hwRatio.x, hwRatio.y);
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const hwRefit = (await hwPanel()).dials.ratio;
|
||||||
|
await t.doubleClick(hwRefit.x, hwRefit.y);
|
||||||
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
|
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
|
||||||
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
|
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
|
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||||||
check(
|
check(
|
||||||
'in the node, a knob turns with a drag up as one undo step and a notch of the wheel without zooming the graph, a double-click resets it without opening the tab, and the lever switches auto-gain',
|
'in the node, a knob turns with a drag up as one undo step, the wheel over it zooms the graph and leaves it, a double-click resets it without opening the tab, and the lever switches auto-gain',
|
||||||
hwDragged.threshold > -50 &&
|
hwDragged.threshold > -50 &&
|
||||||
hwUndone.threshold === -50 &&
|
hwUndone.threshold === -50 &&
|
||||||
hwWheeled.numbers.ratio > 8 &&
|
hwWheeled.numbers.ratio === 8 &&
|
||||||
Math.abs(hwWheeled.panel.rect.w - hwRect.w) < 0.5 &&
|
hwWheeled.panel.rect.w > hwRect.w + 10 &&
|
||||||
hwReset.numbers.ratio === 4 &&
|
hwReset.numbers.ratio === 4 &&
|
||||||
hwReset.docs.active === `asset:${fxId}` &&
|
hwReset.docs.active === `asset:${fxId}` &&
|
||||||
hwLever.numbers.autoGain === true &&
|
hwLever.numbers.autoGain === true &&
|
||||||
|
|||||||
Reference in new issue
Block a user