Edit a Filter in its node and resize EQ and Filter nodes (#385)
A Filter sounds like one band of the Parametric EQ, but it keeps what the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a node of its own and gets the EQ's editor with itself as the one band. The editors also stop running out of their room: what does not fit is left out instead of being cut. - The curve of the EQ is now BandCurve with a host the EQ and the Filter both fill. CurvePanel holds what their panels share. - FilterPanel shows the Filter as one band. A drag of its handle sets frequency and gain as one undo step. A number fed by a link is greyed out and the drag leaves it alone. - A double-click on the Filter's title opens the same in a tab of its own. Delete there does nothing, the node stays. - The Filter is tapped like the EQ, so its node and its tab draw the spectrum of what passes it. A tap is two gain nodes per voice, the analysers are made only while someone listens. - EqFace and FilterFace are one CurveFace now, given the panel. - EQ and Filter nodes have a corner to resize them, drawn like the floating window's. The size is saved on the node as `size`, each drag is one undo step, the least is 240 x 120. - In the node the help line is gone. The row of numbers comes in only once the curve keeps 120 px of height. A group of the row that does not fit is not drawn, the one on the right first. - A frequency or dB label that would run into the one before it is left out. - The all-pass is drawn flat. The curve's maths takes every shape a Filter has. Found on the way: - The instrument presets set nodes 240 apart. A Filter with its editor is about 700 wide and covered the Bitcrusher, so a Filter now takes four columns. Tests: - Unit: 8 new (filter band, resize command and save, fitting rules), stage lists with a Filter now hold its taps - e2e 34 (atx): ALL PASSED, 130 checks (4 new) - Gate (atx): CHECK EXIT 0, 284 files, 4367 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17f47f6956
commit
c087993000
26 files changed
+1514
-656
No files matched your search
@@ -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, 1957 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1961 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
|
||||||
|
|||||||
@@ -332,6 +332,33 @@ function setCollapsed(node: GraphNode, collapsed: boolean): void {
|
|||||||
else delete node.collapsed;
|
else delete node.collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size a node's face was dragged to (#385). The previous size is read at execution time, so a
|
||||||
|
* caller that previewed the drag must restore the node before executing.
|
||||||
|
*/
|
||||||
|
export class ResizeNodeCommand implements Command {
|
||||||
|
readonly label = 'Resize node';
|
||||||
|
private previous: GraphNode['size'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
private readonly size: { w: number; h: number },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
this.previous = node.size && { ...node.size };
|
||||||
|
node.size = { w: Math.round(this.size.w), h: Math.round(this.size.h) };
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
if (this.previous) node.size = { ...this.previous };
|
||||||
|
else delete node.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
|
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
|
||||||
export class ReplaceGraphCommand implements Command {
|
export class ReplaceGraphCommand implements Command {
|
||||||
private previous?: Pick<Graph, 'nodes' | 'edges'>;
|
private previous?: Pick<Graph, 'nodes' | 'edges'>;
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface GraphNode {
|
|||||||
splits?: PortSplit[];
|
splits?: PortSplit[];
|
||||||
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
|
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
|
/** The size its face was dragged to in the graph (#385), for the nodes whose face may be. Purely visual. */
|
||||||
|
size?: { w: number; h: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ function held<K extends string>(
|
|||||||
/** Room between the nodes of a preset, left to right and top to bottom: a Chip is a tall node. */
|
/** Room between the nodes of a preset, left to right and top to bottom: a Chip is a tall node. */
|
||||||
const COLUMN = 240;
|
const COLUMN = 240;
|
||||||
const ROW = 340;
|
const ROW = 340;
|
||||||
|
/** Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide. */
|
||||||
|
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4 };
|
||||||
|
|
||||||
|
/** The column right of a node, past all it takes. */
|
||||||
|
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
|
||||||
|
|
||||||
/** A node to make: its type and its params. */
|
/** A node to make: its type and its params. */
|
||||||
type Step = readonly [type: string, params: PropertyBag];
|
type Step = readonly [type: string, params: PropertyBag];
|
||||||
@@ -416,7 +421,7 @@ class Sketch {
|
|||||||
line(from: GraphNode, ...steps: readonly Step[]): GraphNode {
|
line(from: GraphNode, ...steps: readonly Step[]): GraphNode {
|
||||||
let last = from;
|
let last = from;
|
||||||
for (const [type, params] of steps) {
|
for (const [type, params] of steps) {
|
||||||
const next = this.add(type, last.x / COLUMN + 1, last.y / ROW, params);
|
const next = this.add(type, columnAfter(last), last.y / ROW, params);
|
||||||
this.wire(last, 'out', next, 'in');
|
this.wire(last, 'out', next, 'in');
|
||||||
last = next;
|
last = next;
|
||||||
}
|
}
|
||||||
@@ -510,7 +515,7 @@ const echo = (time: number, feedback: number, wet: number, pingPong = false) =>
|
|||||||
|
|
||||||
/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */
|
/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */
|
||||||
function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode {
|
function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode {
|
||||||
const mix = s.add(SoundFxMix.type, Math.max(a.x, b.x) / COLUMN + 1, Math.min(a.y, b.y) / ROW, {
|
const mix = s.add(SoundFxMix.type, Math.max(columnAfter(a), columnAfter(b)), Math.min(a.y, b.y) / ROW, {
|
||||||
curve,
|
curve,
|
||||||
mix: share,
|
mix: share,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
toneCutoff,
|
toneCutoff,
|
||||||
} from './saturation';
|
} from './saturation';
|
||||||
import { PHASER_STAGES } from './phaser';
|
import { PHASER_STAGES } from './phaser';
|
||||||
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS, type PassBand } from './parametricEq';
|
||||||
|
|
||||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@ 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 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}`,
|
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. Drag the node's lower right corner to make it larger or smaller. 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: [],
|
||||||
@@ -715,11 +715,12 @@ export const SoundFxFilter: SoundFxNodeDef = effect(
|
|||||||
label: 'Filter',
|
label: 'Filter',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
description:
|
||||||
'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep.' +
|
'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep. The node draws what it does as a curve and is edited right there: drag the handle for the frequency and the gain, and type the numbers below it; a number fed by a link cannot be changed there. Double-click the title of the node to open the same in a tab, where the wheel turns the Q over the handle and zooms the dB elsewhere. 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. Drag the lower right corner of the node to make it larger or smaller.' +
|
||||||
BLENDS,
|
BLENDS,
|
||||||
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
|
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [PASS, SLOPE],
|
params: [PASS, SLOPE],
|
||||||
|
tapped: true,
|
||||||
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
|
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
|
||||||
derivePorts: ({ node }) => ({
|
derivePorts: ({ node }) => ({
|
||||||
inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [],
|
inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [],
|
||||||
@@ -739,6 +740,28 @@ export const SoundFxFilter: SoundFxNodeDef = effect(
|
|||||||
'blend',
|
'blend',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The shapes a Filter offers and its two settings, for its editor (#385). */
|
||||||
|
export const FILTER_TYPES: readonly { value: FxPassFilter; label: string }[] = PASS_FILTERS;
|
||||||
|
export const FILTER_SETTINGS = { type: PASS.key, slope: SLOPE.key };
|
||||||
|
|
||||||
|
/** A Filter as one band of the curve, from what is typed into it (#385). */
|
||||||
|
export function filterBandOf(params: PropertyBag): PassBand {
|
||||||
|
const type = passOf(params);
|
||||||
|
const number = (row: FlatDef<'number'>) => {
|
||||||
|
const value = params[row.key];
|
||||||
|
const n = typeof value === 'number' && Number.isFinite(value) ? value : row.default;
|
||||||
|
return Math.max(row.min ?? n, Math.min(row.max ?? n, n));
|
||||||
|
};
|
||||||
|
const slope = Number(params[SLOPE.key]);
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
frequency: number(CUTOFF),
|
||||||
|
gain: GAINED.has(type) ? number(FILTER_GAIN) : 0,
|
||||||
|
q: number(Q),
|
||||||
|
slope: slope === 24 || slope === 48 ? slope : 12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
|
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
|
||||||
const MIX_CURVE = {
|
const MIX_CURVE = {
|
||||||
key: 'curve',
|
key: 'curve',
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export const MAX_EQ_BANDS = 10;
|
|||||||
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
|
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
|
||||||
export type EqSlope = 12 | 24 | 48;
|
export type EqSlope = 12 | 24 | 48;
|
||||||
|
|
||||||
export interface EqBand {
|
/** One filter as the curve draws it: a band of the EQ, or a Filter node (#385), all-pass and all. */
|
||||||
type: EqBandType;
|
export interface PassBand {
|
||||||
|
type: FxPassFilter;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
gain: number;
|
gain: number;
|
||||||
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
|
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
|
||||||
@@ -21,6 +22,10 @@ export interface EqBand {
|
|||||||
slope: EqSlope;
|
slope: EqSlope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EqBand extends PassBand {
|
||||||
|
type: EqBandType;
|
||||||
|
}
|
||||||
|
|
||||||
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
|
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
|
||||||
{ value: 'peaking', label: 'Peaking' },
|
{ value: 'peaking', label: 'Peaking' },
|
||||||
{ value: 'lowshelf', label: 'Low shelf' },
|
{ value: 'lowshelf', label: 'Low shelf' },
|
||||||
@@ -37,13 +42,13 @@ export const EQ_GAIN = { min: -24, max: 24 };
|
|||||||
export const EQ_Q = { min: 0.1, max: 30 };
|
export const EQ_Q = { min: 0.1, max: 30 };
|
||||||
|
|
||||||
/** The shapes with a gain, and those a slope makes steeper. */
|
/** The shapes with a gain, and those a slope makes steeper. */
|
||||||
const GAINED: ReadonlySet<EqBandType> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
const GAINED: ReadonlySet<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
||||||
const SLOPED: ReadonlySet<EqBandType> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
const SLOPED: ReadonlySet<FxPassFilter> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
||||||
|
|
||||||
export const hasGain = (type: EqBandType): boolean => GAINED.has(type);
|
export const hasGain = (type: FxPassFilter): boolean => GAINED.has(type);
|
||||||
export const hasSlope = (type: EqBandType): boolean => SLOPED.has(type);
|
export const hasSlope = (type: FxPassFilter): boolean => SLOPED.has(type);
|
||||||
/** Web Audio's shelves have no Q: theirs is fixed. */
|
/** Web Audio's shelves have no Q: theirs is fixed. */
|
||||||
export const hasQ = (type: EqBandType): boolean => type !== 'lowshelf' && type !== 'highshelf';
|
export const hasQ = (type: FxPassFilter): boolean => type !== 'lowshelf' && type !== 'highshelf';
|
||||||
|
|
||||||
const FLAT_Q = Math.SQRT1_2;
|
const FLAT_Q = Math.SQRT1_2;
|
||||||
|
|
||||||
@@ -92,10 +97,10 @@ export function withBandSet(bands: readonly EqBand[], index: number, change: Par
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a band changes the sound at all: a peak or a shelf at 0 dB does not. */
|
/** Whether a band changes the sound at all: a peak or a shelf at 0 dB does not. */
|
||||||
export const isEqBandHeard = (band: EqBand): boolean => !hasGain(band.type) || band.gain !== 0;
|
export const isEqBandHeard = (band: PassBand): boolean => !hasGain(band.type) || band.gain !== 0;
|
||||||
|
|
||||||
/** How many filters a band runs in a row: one, or more for a steeper slope. */
|
/** How many filters a band runs in a row: one, or more for a steeper slope. */
|
||||||
export const eqStagesOf = (band: EqBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
|
export const eqStagesOf = (band: PassBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
|
||||||
|
|
||||||
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
|
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
|
||||||
const EQ_SAMPLE_RATE = 48000;
|
const EQ_SAMPLE_RATE = 48000;
|
||||||
@@ -110,7 +115,7 @@ interface Biquad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The coefficients of one filter as the Web Audio specification gives them. */
|
/** The coefficients of one filter as the Web Audio specification gives them. */
|
||||||
function biquad(type: EqBandType, frequency: number, q: number, gain: number, rate: number): Biquad {
|
function biquad(type: FxPassFilter, frequency: number, q: number, gain: number, rate: number): Biquad {
|
||||||
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
|
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
|
||||||
const cos = Math.cos(w0);
|
const cos = Math.cos(w0);
|
||||||
const sin = Math.sin(w0);
|
const sin = Math.sin(w0);
|
||||||
@@ -142,6 +147,8 @@ function biquad(type: EqBandType, frequency: number, q: number, gain: number, ra
|
|||||||
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||||||
case 'notch':
|
case 'notch':
|
||||||
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||||||
|
case 'allpass':
|
||||||
|
return { b0: 1 - alpha, b1: -2 * cos, b2: 1 + alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||||||
case 'peaking':
|
case 'peaking':
|
||||||
return {
|
return {
|
||||||
b0: 1 + alpha * a,
|
b0: 1 + alpha * a,
|
||||||
@@ -190,7 +197,7 @@ function biquadDb(filter: Biquad, frequency: number, rate: number): number {
|
|||||||
* What one band does, in dB at any frequency – its filters in a row, only the last with its Q. The
|
* What one band does, in dB at any frequency – its filters in a row, only the last with its Q. The
|
||||||
* coefficients are worked out once, for a curve of many points.
|
* coefficients are worked out once, for a curve of many points.
|
||||||
*/
|
*/
|
||||||
export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
|
export function eqBandResponse(band: PassBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
|
||||||
const stages = eqStagesOf(band);
|
const stages = eqStagesOf(band);
|
||||||
const gain = hasGain(band.type) ? band.gain : 0;
|
const gain = hasGain(band.type) ? band.gain : 0;
|
||||||
const last = biquad(band.type, band.frequency, band.q, gain, rate);
|
const last = biquad(band.type, band.frequency, band.q, gain, rate);
|
||||||
@@ -207,7 +214,7 @@ export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency:
|
|||||||
* together: the lines the EQ tab draws. Each band's coefficients are worked out once.
|
* together: the lines the EQ tab draws. Each band's coefficients are worked out once.
|
||||||
*/
|
*/
|
||||||
export function eqCurves(
|
export function eqCurves(
|
||||||
bands: readonly EqBand[],
|
bands: readonly PassBand[],
|
||||||
frequencies: readonly number[],
|
frequencies: readonly number[],
|
||||||
rate = EQ_SAMPLE_RATE,
|
rate = EQ_SAMPLE_RATE,
|
||||||
): { bands: number[][]; sum: number[] } {
|
): { bands: number[][]; sum: number[] } {
|
||||||
@@ -258,7 +265,7 @@ export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (a
|
|||||||
* stays within `overflow` of the area, so a deep cut runs out of sight rather than down to -300 dB.
|
* stays within `overflow` of the area, so a deep cut runs out of sight rather than down to -300 dB.
|
||||||
*/
|
*/
|
||||||
export function eqCurvePoints(
|
export function eqCurvePoints(
|
||||||
bands: readonly EqBand[],
|
bands: readonly PassBand[],
|
||||||
area: EqArea,
|
area: EqArea,
|
||||||
steps: number,
|
steps: number,
|
||||||
overflow = 0,
|
overflow = 0,
|
||||||
|
|||||||
@@ -595,6 +595,10 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
|
|||||||
const splits = normalizeSplits(n.splits);
|
const splits = normalizeSplits(n.splits);
|
||||||
if (splits.length > 0) node.splits = splits;
|
if (splits.length > 0) node.splits = splits;
|
||||||
if (n.collapsed === true) node.collapsed = true;
|
if (n.collapsed === true) node.collapsed = true;
|
||||||
|
const size = asRecord(n.size);
|
||||||
|
const w = toNumber(size.w);
|
||||||
|
const h = toNumber(size.h);
|
||||||
|
if (w > 0 && h > 0) node.size = { w, h };
|
||||||
graph.nodes.push(node);
|
graph.nodes.push(node);
|
||||||
}
|
}
|
||||||
const edgeIds = new Set<string>();
|
const edgeIds = new Set<string>();
|
||||||
|
|||||||
+17
-2
@@ -125,6 +125,7 @@ export type CentreTab =
|
|||||||
| 'routing'
|
| 'routing'
|
||||||
| 'eq'
|
| 'eq'
|
||||||
| 'compressor'
|
| 'compressor'
|
||||||
|
| 'filter'
|
||||||
| 'management';
|
| 'management';
|
||||||
|
|
||||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||||
@@ -135,7 +136,7 @@ export type TabId = CentreTab | PluginTabId;
|
|||||||
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
|
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
|
||||||
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
|
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
|
||||||
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
|
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
|
||||||
* and so has a Compressor (#378).
|
* and so have a Compressor (#378) and a Filter (#385).
|
||||||
*/
|
*/
|
||||||
export type DocumentId =
|
export type DocumentId =
|
||||||
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
|
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
|
||||||
@@ -183,6 +184,7 @@ const PANE_ORDER = [
|
|||||||
'routing',
|
'routing',
|
||||||
'eq',
|
'eq',
|
||||||
'compressor',
|
'compressor',
|
||||||
|
'filter',
|
||||||
'management',
|
'management',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -210,6 +212,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
|||||||
routing: 'Master routing',
|
routing: 'Master routing',
|
||||||
eq: 'Parametric EQ',
|
eq: 'Parametric EQ',
|
||||||
compressor: 'Compressor',
|
compressor: 'Compressor',
|
||||||
|
filter: 'Filter',
|
||||||
management: 'User management',
|
management: 'User management',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -279,6 +282,7 @@ export class Editor implements PlayHost {
|
|||||||
readonly routingEditor: RoutingEditor;
|
readonly routingEditor: RoutingEditor;
|
||||||
readonly eqEditor: EqEditor;
|
readonly eqEditor: EqEditor;
|
||||||
readonly compressorEditor: CompressorEditor;
|
readonly compressorEditor: CompressorEditor;
|
||||||
|
readonly filterEditor: EqEditor;
|
||||||
/** What each open Variables tab shows, by its document. */
|
/** What each open Variables tab shows, by its document. */
|
||||||
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
||||||
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
|
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
|
||||||
@@ -384,6 +388,7 @@ export class Editor implements PlayHost {
|
|||||||
this.routingEditor = new RoutingEditor(state);
|
this.routingEditor = new RoutingEditor(state);
|
||||||
this.eqEditor = new EqEditor(state);
|
this.eqEditor = new EqEditor(state);
|
||||||
this.compressorEditor = new CompressorEditor(state);
|
this.compressorEditor = new CompressorEditor(state);
|
||||||
|
this.filterEditor = new EqEditor(state, 'filter');
|
||||||
this.hierarchy = new HierarchyPanel(state);
|
this.hierarchy = new HierarchyPanel(state);
|
||||||
this.inspector = new Inspector(state);
|
this.inspector = new Inspector(state);
|
||||||
this.tilemapPanel = new TilemapPanel(state);
|
this.tilemapPanel = new TilemapPanel(state);
|
||||||
@@ -508,6 +513,7 @@ export class Editor implements PlayHost {
|
|||||||
routing: this.routingEditor,
|
routing: this.routingEditor,
|
||||||
eq: this.eqEditor,
|
eq: this.eqEditor,
|
||||||
compressor: this.compressorEditor,
|
compressor: this.compressorEditor,
|
||||||
|
filter: this.filterEditor,
|
||||||
management: this.managementPane,
|
management: this.managementPane,
|
||||||
};
|
};
|
||||||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||||
@@ -614,7 +620,7 @@ export class Editor implements PlayHost {
|
|||||||
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
|
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
|
||||||
this.openDocument(doc);
|
this.openDocument(doc);
|
||||||
});
|
});
|
||||||
// A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it.
|
// A tab per Parametric EQ (#370), Compressor (#378) or Filter (#385) node, opened by a double-click on it.
|
||||||
state.on('nodeTab', () => {
|
state.on('nodeTab', () => {
|
||||||
const target = state.openedNode;
|
const target = state.openedNode;
|
||||||
const kind = target && nodeTabOf(target.type);
|
const kind = target && nodeTabOf(target.type);
|
||||||
@@ -642,6 +648,7 @@ export class Editor implements PlayHost {
|
|||||||
this.nodeTargets.clear();
|
this.nodeTargets.clear();
|
||||||
this.eqEditor.close();
|
this.eqEditor.close();
|
||||||
this.compressorEditor.close();
|
this.compressorEditor.close();
|
||||||
|
this.filterEditor.close();
|
||||||
this.showTab('scene');
|
this.showTab('scene');
|
||||||
});
|
});
|
||||||
state.on('mode', () => this.editorRun.modeChanged());
|
state.on('mode', () => this.editorRun.modeChanged());
|
||||||
@@ -712,6 +719,9 @@ export class Editor implements PlayHost {
|
|||||||
// The selection is the EQ's node, still selected from the double-click: it is never what goes.
|
// The selection is the EQ's node, still selected from the double-click: it is never what goes.
|
||||||
this.eqEditor.removePicked();
|
this.eqEditor.removePicked();
|
||||||
return true;
|
return true;
|
||||||
|
case 'filter':
|
||||||
|
// A Filter has its one band and nothing else to take; its node stays as well.
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -908,6 +918,7 @@ export class Editor implements PlayHost {
|
|||||||
if (this.nodeTargets.delete(doc)) {
|
if (this.nodeTargets.delete(doc)) {
|
||||||
if (kind === 'eq') this.eqEditor.close();
|
if (kind === 'eq') this.eqEditor.close();
|
||||||
if (kind === 'compressor') this.compressorEditor.close();
|
if (kind === 'compressor') this.compressorEditor.close();
|
||||||
|
if (kind === 'filter') this.filterEditor.close();
|
||||||
}
|
}
|
||||||
if (doc === 'routing') this.routingEditor.close();
|
if (doc === 'routing') this.routingEditor.close();
|
||||||
}
|
}
|
||||||
@@ -1145,6 +1156,7 @@ export class Editor implements PlayHost {
|
|||||||
const node = this.nodeTargets.get(doc);
|
const node = this.nodeTargets.get(doc);
|
||||||
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
|
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
|
||||||
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
|
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
|
||||||
|
if (kind === 'filter' && node) this.filterEditor.show(node.assetId, node.nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
||||||
@@ -1335,9 +1347,11 @@ export class Editor implements PlayHost {
|
|||||||
['instrument', this.instrumentEditor.canvas],
|
['instrument', this.instrumentEditor.canvas],
|
||||||
] as const;
|
] as const;
|
||||||
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
|
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
|
||||||
|
if (!this.shows('filter')) this.filterEditor.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('eq')) this.eqEditor.tick();
|
||||||
|
if (this.shows('filter')) this.filterEditor.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)
|
||||||
@@ -1379,6 +1393,7 @@ export class Editor implements PlayHost {
|
|||||||
(this.shows('song') && this.songEditor.isPlaying) ||
|
(this.shows('song') && this.songEditor.isPlaying) ||
|
||||||
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
||||||
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
|
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
|
||||||
|
(this.shows('filter') && this.filterEditor.hasSpectrum) ||
|
||||||
(this.shows('compressor') && this.compressorEditor.moving) ||
|
(this.shows('compressor') && this.compressorEditor.moving) ||
|
||||||
this.facesMoving
|
this.facesMoving
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 { GainPanel } from '../soundfx/GainPanel';
|
import { GainPanel } from '../soundfx/GainPanel';
|
||||||
import { EqPanel } from '../soundfx/EqPanel';
|
import { CurvePanel } from '../soundfx/CurvePanel';
|
||||||
|
import type { EqEditor } from '../soundfx/EqEditor';
|
||||||
import { rectOf } from '../textProbe';
|
import { rectOf } from '../textProbe';
|
||||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||||
|
|
||||||
@@ -127,17 +128,7 @@ 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),
|
...curveTabHooks(editor.eqEditor, () => editor.tab === 'eq'),
|
||||||
bands: () => editor.eqEditor.panel.bands(),
|
|
||||||
picked: () => editor.eqEditor.panel.picked,
|
|
||||||
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
|
||||||
range: () => editor.eqEditor.panel.range(),
|
|
||||||
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
|
|
||||||
zoomShown: () => editor.eqEditor.panel.curve.zoomShown,
|
|
||||||
handleAt: (index: number) => editor.eqEditor.panel.curve.handleGlobal(index),
|
|
||||||
pointAt: (hz: number, db: number) => editor.eqEditor.panel.curve.pointGlobal(hz, db),
|
|
||||||
fields: () => eqFields(editor.eqEditor.panel),
|
|
||||||
typeAt: () => centreOf(editor.eqEditor.panel.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
|
||||||
@@ -151,7 +142,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
panel: (nodeId: string) => {
|
panel: (nodeId: string) => {
|
||||||
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 EqPanel)) return null;
|
if (!(panel instanceof CurvePanel)) return null;
|
||||||
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)]));
|
||||||
@@ -161,6 +152,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
size: { w: panel.w, h: panel.h },
|
size: { w: panel.w, h: panel.h },
|
||||||
curve: screenRect(panel.curve),
|
curve: screenRect(panel.curve),
|
||||||
node: node?.rect ?? null,
|
node: node?.rect ?? null,
|
||||||
|
corner: node?.corner ?? null,
|
||||||
inputs: ports(node?.inputs ?? [], false),
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
outputs: ports(node?.outputs ?? [], true),
|
outputs: ports(node?.outputs ?? [], true),
|
||||||
bands: panel.bands(),
|
bands: panel.bands(),
|
||||||
@@ -169,14 +161,21 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)),
|
handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)),
|
||||||
type: screenRect(panel.typeButton),
|
type: screenRect(panel.typeButton),
|
||||||
spectrum: panel.curve.seen,
|
spectrum: panel.curve.seen,
|
||||||
|
rows: panel.shownRows,
|
||||||
|
labels: panel.curve.shownLabels,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
/** Where a frequency and a gain are on the curve of a node's panel, on screen. */
|
/** Where a frequency and a gain are on the curve of a node's panel, on screen. */
|
||||||
panelPointAt: (nodeId: string, hz: number, db: number) => {
|
panelPointAt: (nodeId: string, hz: number, db: number) => {
|
||||||
const panel = editor.soundFxEditor.canvas.faceOf(nodeId)?.widget;
|
const panel = editor.soundFxEditor.canvas.faceOf(nodeId)?.widget;
|
||||||
return panel instanceof EqPanel ? panel.curve.pointGlobal(hz, db) : null;
|
return panel instanceof CurvePanel ? panel.curve.pointGlobal(hz, db) : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/** The Filter's tab (#385), as the EQ's; its node's panel is `eq.panel` too. */
|
||||||
|
filter: {
|
||||||
|
...curveTabHooks(editor.filterEditor, () => editor.tab === 'filter'),
|
||||||
|
spectrum: () => ({ ...editor.filterEditor.panel.curve.seen, taps: soundLibrary.spectrumTaps() }),
|
||||||
|
},
|
||||||
/** 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: {
|
||||||
shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null),
|
shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null),
|
||||||
@@ -381,7 +380,7 @@ function screenRect(widget: Widget): { x: number; y: number; w: number; h: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
|
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
|
||||||
function eqFields(panel: EqPanel) {
|
function eqFields(panel: CurvePanel) {
|
||||||
return {
|
return {
|
||||||
type: panel.typeButton.text,
|
type: panel.typeButton.text,
|
||||||
slope: panel.slopeButton.enabled ? panel.slopeButton.text : null,
|
slope: panel.slopeButton.enabled ? panel.slopeButton.text : null,
|
||||||
@@ -390,3 +389,24 @@ function eqFields(panel: EqPanel) {
|
|||||||
frequency: panel.frequencyField.value,
|
frequency: panel.frequencyField.value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tab with a curve (#370, #385): for which node it is showing, its bands and where to aim. */
|
||||||
|
function curveTabHooks(tab: EqEditor, seen: () => boolean) {
|
||||||
|
const panel = tab.panel;
|
||||||
|
return {
|
||||||
|
shown: () => (seen() ? tab.shownFor : null),
|
||||||
|
bands: () => panel.bands(),
|
||||||
|
picked: () => panel.picked,
|
||||||
|
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
||||||
|
range: () => panel.range(),
|
||||||
|
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
|
||||||
|
zoomShown: () => panel.curve.zoomShown,
|
||||||
|
handleAt: (index: number) => panel.curve.handleGlobal(index),
|
||||||
|
pointAt: (hz: number, db: number) => panel.curve.pointGlobal(hz, db),
|
||||||
|
fields: () => eqFields(panel),
|
||||||
|
typeAt: () => centreOf(panel.typeButton),
|
||||||
|
/** What is drawn under the curve, and which axis labels (#385). */
|
||||||
|
rows: () => panel.shownRows,
|
||||||
|
labels: () => panel.curve.shownLabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -371,7 +371,11 @@ export class GraphCanvas extends Widget {
|
|||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
this.annotationLayer.sync(asset.graph);
|
this.annotationLayer.sync(asset.graph);
|
||||||
for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y);
|
for (const node of asset.graph.nodes) {
|
||||||
|
const view = this.views.get(node.id);
|
||||||
|
view?.position.set(node.x, node.y);
|
||||||
|
view?.refit();
|
||||||
|
}
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,14 +543,17 @@ export class GraphCanvas extends Widget {
|
|||||||
return this.views.get(nodeId)?.face ?? null;
|
return this.views.get(nodeId)?.face ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A node's box on screen and the keys of its ports, for the check scripts. */
|
/** A node's box on screen, the keys of its ports and the corner it is resized by (#385), for the check scripts. */
|
||||||
nodeScreen(nodeId: string): { rect: Rect; inputs: string[]; outputs: string[] } | null {
|
nodeScreen(
|
||||||
|
nodeId: string,
|
||||||
|
): { rect: Rect; inputs: string[]; outputs: string[]; corner: { x: number; y: number } | null } | null {
|
||||||
const view = this.views.get(nodeId);
|
const view = this.views.get(nodeId);
|
||||||
if (!view) return null;
|
if (!view) return null;
|
||||||
return {
|
return {
|
||||||
rect: globalRect(view, view.w, view.h),
|
rect: globalRect(view, view.w, view.h),
|
||||||
inputs: view.def.inputs.map((p) => p.key),
|
inputs: view.def.inputs.map((p) => p.key),
|
||||||
outputs: view.def.outputs.map((p) => p.key),
|
outputs: view.def.outputs.map((p) => p.key),
|
||||||
|
corner: view.cornerGlobal(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +704,12 @@ export class GraphCanvas extends Widget {
|
|||||||
this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global);
|
this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (view.cornerAt(local.x, local.y)) {
|
||||||
|
this.state.select(nodeSelection(this.assetId, [view.node.id]));
|
||||||
|
this.nodeLayer.addChild(view); // raise
|
||||||
|
this.drags.grabCorner(view, e.global);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
|
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
|
||||||
if (view.chevronAt(local.x, local.y)) {
|
if (view.chevronAt(local.x, local.y)) {
|
||||||
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
|
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
|
||||||
@@ -843,7 +856,7 @@ export class GraphCanvas extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.panning = null;
|
this.panning = null;
|
||||||
if (this.drags.releaseBox()) {
|
if (this.drags.releaseCorner() || this.drags.releaseBox()) {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js';
|
import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js';
|
||||||
import {
|
import {
|
||||||
asNumber,
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
crispText,
|
crispText,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||||||
|
import { RESIZE_HANDLE } from './AnnotationView';
|
||||||
import { collapsedNodeWidth } from './folding';
|
import { collapsedNodeWidth } from './folding';
|
||||||
import type { NodeFace } from './nodeFace';
|
import type { NodeFace } from './nodeFace';
|
||||||
import { inputTextRoom, valueSpan } from './portRow';
|
import { inputTextRoom, valueSpan } from './portRow';
|
||||||
@@ -164,6 +165,10 @@ export class NodeView extends Widget {
|
|||||||
private drawnOf = '';
|
private drawnOf = '';
|
||||||
/** 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 };
|
||||||
|
/** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */
|
||||||
|
private readonly corner = new Container();
|
||||||
|
/** What the node was last drawn from, so a drag of its corner can draw it again (#385). */
|
||||||
|
private last: { graph: Graph; project: Project | null; problem: string | null } | null = null;
|
||||||
/** 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. */
|
||||||
readonly made = { texts: 0, box: 0 };
|
readonly made = { texts: 0, box: 0 };
|
||||||
|
|
||||||
@@ -188,6 +193,10 @@ export class NodeView extends Widget {
|
|||||||
this.ports.context.batchMode = 'batch';
|
this.ports.context.batchMode = 'batch';
|
||||||
this.addChild(this.box, this.ports);
|
this.addChild(this.box, this.ports);
|
||||||
if (face) this.addChild(face.widget);
|
if (face) this.addChild(face.widget);
|
||||||
|
this.corner.eventMode = 'static';
|
||||||
|
this.corner.cursor = 'nwse-resize';
|
||||||
|
this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE);
|
||||||
|
this.addChild(this.corner);
|
||||||
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +225,7 @@ export class NodeView extends Widget {
|
|||||||
): void {
|
): void {
|
||||||
this.def = def;
|
this.def = def;
|
||||||
this.problem = problem;
|
this.problem = problem;
|
||||||
|
this.last = { graph, project, problem };
|
||||||
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
|
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
|
||||||
const hidden = new Set([
|
const hidden = new Set([
|
||||||
...(this.def.hiddenParams?.(node.params) ?? []),
|
...(this.def.hiddenParams?.(node.params) ?? []),
|
||||||
@@ -284,6 +294,8 @@ export class NodeView extends Widget {
|
|||||||
if (face) this.placeFace(face);
|
if (face) this.placeFace(face);
|
||||||
else this.face.hide();
|
else this.face.hide();
|
||||||
}
|
}
|
||||||
|
this.corner.visible = this.resizable;
|
||||||
|
this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE);
|
||||||
const texts = this.textSignature();
|
const texts = this.textSignature();
|
||||||
if (texts !== this.textsOf) {
|
if (texts !== this.textsOf) {
|
||||||
this.textsOf = texts;
|
this.textsOf = texts;
|
||||||
@@ -324,6 +336,7 @@ export class NodeView extends Widget {
|
|||||||
this.linked,
|
this.linked,
|
||||||
this.targetPort,
|
this.targetPort,
|
||||||
this.headerHeight(),
|
this.headerHeight(),
|
||||||
|
this.resizable,
|
||||||
this.def.inputs.map(port),
|
this.def.inputs.map(port),
|
||||||
this.def.outputs.map(port),
|
this.def.outputs.map(port),
|
||||||
]);
|
]);
|
||||||
@@ -428,10 +441,52 @@ export class NodeView extends Widget {
|
|||||||
return (!this.collapsed && this.face) || null;
|
return (!this.collapsed && this.face) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */
|
||||||
private faceSize(): { w: number; h: number } {
|
private faceSize(): { w: number; h: number } {
|
||||||
|
const min = this.face?.minSize;
|
||||||
|
const size = this.node.size;
|
||||||
|
if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) };
|
||||||
return this.face?.widget.measure() ?? { w: 0, h: 0 };
|
return this.face?.widget.measure() ?? { w: 0, h: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */
|
||||||
|
get resizable(): boolean {
|
||||||
|
return this.shownFace()?.minSize !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a local point is on that corner. */
|
||||||
|
cornerAt(x: number, y: number): boolean {
|
||||||
|
return (
|
||||||
|
this.resizable &&
|
||||||
|
x >= this.w - RESIZE_HANDLE &&
|
||||||
|
x <= this.w &&
|
||||||
|
y >= this.h - RESIZE_HANDLE &&
|
||||||
|
y <= this.h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cornerGlobal(): { x: number; y: number } | null {
|
||||||
|
if (!this.resizable) return null;
|
||||||
|
const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 });
|
||||||
|
return { x: at.x, y: at.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of the node is not its face: the least the node itself can be dragged to follows from it. */
|
||||||
|
get faceChrome(): { w: number; h: number } {
|
||||||
|
const size = this.faceSize();
|
||||||
|
return { w: this.w - size.w, h: this.h - size.h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drag of the corner changed the node's size in the model: the node is drawn at it. */
|
||||||
|
refit(): void {
|
||||||
|
const last = this.last;
|
||||||
|
const face = this.shownFace();
|
||||||
|
if (!last || !face) return;
|
||||||
|
const size = this.faceSize();
|
||||||
|
if (size.w === face.widget.w && size.h === face.widget.h) return;
|
||||||
|
this.sync(this.node, last.graph, last.project, this.def, last.problem);
|
||||||
|
}
|
||||||
|
|
||||||
/** Each side of a face as wide as the longest name on it. */
|
/** Each side of a face as wide as the longest name on it. */
|
||||||
private faceSides(): { left: number; right: number } {
|
private faceSides(): { left: number; right: number } {
|
||||||
const side = (ports: PortDef[]) =>
|
const side = (ports: PortDef[]) =>
|
||||||
@@ -591,6 +646,12 @@ export class NodeView extends Widget {
|
|||||||
this.box
|
this.box
|
||||||
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
|
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
|
||||||
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
|
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
|
||||||
|
if (this.resizable) {
|
||||||
|
// The floating window's corner (#371), so it reads as the same handle.
|
||||||
|
for (const at of [4, 8, 12])
|
||||||
|
this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at);
|
||||||
|
this.box.stroke({ width: 1, color: c.textMuted });
|
||||||
|
}
|
||||||
|
|
||||||
this.ports.clear();
|
this.ports.clear();
|
||||||
const r = PORT_RADIUS;
|
const r = PORT_RADIUS;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands';
|
import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands';
|
||||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
|
import { ResizeNodeCommand } from '../../core/commands/graphCommands';
|
||||||
import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph';
|
import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph';
|
||||||
import { cancelFrameWork, onFrame } from '../../ui';
|
import { cancelFrameWork, onFrame } from '../../ui';
|
||||||
import { dragOffset, resizeSize } from '../framing';
|
import { type Box, dragOffset, resizeSize } from '../framing';
|
||||||
import {
|
import {
|
||||||
boxOf,
|
boxOf,
|
||||||
type BoxSeed,
|
type BoxSeed,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import type { AnnotationView } from './AnnotationView';
|
import type { AnnotationView } from './AnnotationView';
|
||||||
import type { GraphCanvasHost } from './graphHost';
|
import type { GraphCanvasHost } from './graphHost';
|
||||||
import { GRID_STEP } from './graphView';
|
import { GRID_STEP } from './graphView';
|
||||||
|
import type { NodeView } from './NodeView';
|
||||||
import type { Point } from './transitions';
|
import type { Point } from './transitions';
|
||||||
|
|
||||||
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
||||||
@@ -38,6 +40,19 @@ export class GraphDrags {
|
|||||||
pointer: Point;
|
pointer: Point;
|
||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
/**
|
||||||
|
* A node dragged by its corner (#385): the node as it was grabbed, how much of it is not its face,
|
||||||
|
* and the least it can be. The model holds the face's size, the drag works on the whole node.
|
||||||
|
*/
|
||||||
|
private corner: {
|
||||||
|
nodeId: string;
|
||||||
|
start: Omit<Box, 'id'>;
|
||||||
|
chrome: { w: number; h: number };
|
||||||
|
min: { w: number; h: number };
|
||||||
|
before: { w: number; h: number } | undefined;
|
||||||
|
pointer: Point;
|
||||||
|
moved: boolean;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and
|
* The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and
|
||||||
@@ -90,9 +105,27 @@ export class GraphDrags {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A press on the corner of a node whose face may be resized (#385). */
|
||||||
|
grabCorner(view: NodeView, global: Point): void {
|
||||||
|
const min = view.face?.minSize;
|
||||||
|
if (!min) return;
|
||||||
|
const chrome = view.faceChrome;
|
||||||
|
const node = view.node;
|
||||||
|
this.corner = {
|
||||||
|
nodeId: node.id,
|
||||||
|
start: { x: node.x, y: node.y, w: view.w, h: view.h },
|
||||||
|
chrome,
|
||||||
|
min: { w: min.w + chrome.w, h: min.h + chrome.h },
|
||||||
|
before: node.size && { ...node.size },
|
||||||
|
pointer: this.host.toGraph(global),
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Moves what is dragged in the model, for the preview; false when nothing is. */
|
/** Moves what is dragged in the model, for the preview; false when nothing is. */
|
||||||
move(global: Point): boolean {
|
move(global: Point): boolean {
|
||||||
if (this.box) this.previewBox(this.host.toGraph(global));
|
if (this.corner) this.previewCorner(this.host.toGraph(global));
|
||||||
|
else if (this.box) this.previewBox(this.host.toGraph(global));
|
||||||
else if (this.nodes) this.previewNodes(this.host.toGraph(global));
|
else if (this.nodes) this.previewNodes(this.host.toGraph(global));
|
||||||
else return false;
|
else return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -135,6 +168,39 @@ export class GraphDrags {
|
|||||||
this.follow();
|
this.follow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sizes the node in the model, for the preview: its far corner on the grid when snapping, as a group's. */
|
||||||
|
private previewCorner(g: Point): void {
|
||||||
|
const drag = this.corner!;
|
||||||
|
const dx = g.x - drag.pointer.x;
|
||||||
|
const dy = g.y - drag.pointer.y;
|
||||||
|
if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return;
|
||||||
|
drag.moved = true;
|
||||||
|
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const size = resizeSize(drag.start, dx, dy, this.host.snapping() ? GRID_STEP : 0, drag.min);
|
||||||
|
node.size = {
|
||||||
|
w: Math.round(Math.max(drag.min.w, size.w) - drag.chrome.w),
|
||||||
|
h: Math.round(Math.max(drag.min.h, size.h) - drag.chrome.h),
|
||||||
|
};
|
||||||
|
this.follow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the node's size back and records it as one undo step. False when no corner was dragged. */
|
||||||
|
releaseCorner(): boolean {
|
||||||
|
const drag = this.corner;
|
||||||
|
if (!drag) return false;
|
||||||
|
this.corner = null;
|
||||||
|
cancelFrameWork(this.frameKey);
|
||||||
|
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||||
|
const assetId = this.host.assetId();
|
||||||
|
if (!drag.moved || !node?.size || !assetId) return true;
|
||||||
|
const size = node.size;
|
||||||
|
if (drag.before) node.size = drag.before;
|
||||||
|
else delete node.size;
|
||||||
|
this.host.state.execute(new ResizeNodeCommand(assetId, node.id, size));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
|
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
|
||||||
releaseBox(): boolean {
|
releaseBox(): boolean {
|
||||||
const drag = this.box;
|
const drag = this.box;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { Widget } from '../../ui';
|
|||||||
*/
|
*/
|
||||||
export interface NodeFace {
|
export interface NodeFace {
|
||||||
readonly widget: Widget;
|
readonly widget: Widget;
|
||||||
|
/**
|
||||||
|
* The least it can be dragged to, for a face whose node has a corner to resize it by (#385); the
|
||||||
|
* widget's `measure()` is its size until then.
|
||||||
|
*/
|
||||||
|
readonly minSize?: { w: number; h: number };
|
||||||
/** After every change to the model: the widget shows the node as it now is. */
|
/** After every change to the model: the widget shows the node as it now is. */
|
||||||
sync(): void;
|
sync(): void;
|
||||||
/** A frame on while the node is in the picture. Whether it still moves. */
|
/** A frame on while the node is in the picture. Whether it still moves. */
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* The curve of a Parametric EQ (#367) and of a Filter (#385): what its bands do together, worked out
|
||||||
|
* as Web Audio plays them (`parametricEq.ts`). A band is dragged for frequency and gain and wheeled
|
||||||
|
* for its Q. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the
|
||||||
|
* tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what
|
||||||
|
* passes the node may be drawn behind the curve (#374).
|
||||||
|
*/
|
||||||
|
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
|
||||||
|
import {
|
||||||
|
dbToY,
|
||||||
|
type EqArea,
|
||||||
|
EQ_RANGES,
|
||||||
|
eqCurvePoints,
|
||||||
|
fillShapes,
|
||||||
|
frequencyToX,
|
||||||
|
hasGain,
|
||||||
|
hasQ,
|
||||||
|
MAX_EQ_BANDS,
|
||||||
|
type PassBand,
|
||||||
|
xToFrequency,
|
||||||
|
yToDb,
|
||||||
|
} from '../../core/soundfx/parametricEq';
|
||||||
|
import type { Spectrum } from '../../viewport/spectrum';
|
||||||
|
import { crispText, pressCount, Theme, textStyle, Widget } from '../../ui';
|
||||||
|
import { ViewTween } from '../viewTween';
|
||||||
|
import {
|
||||||
|
apartLabels,
|
||||||
|
bandAt,
|
||||||
|
EQ_GRID_FREQUENCIES,
|
||||||
|
eqGridDb,
|
||||||
|
handleOf,
|
||||||
|
SPECTRUM_FLOOR_DB,
|
||||||
|
spectrumPeak,
|
||||||
|
spectrumYs,
|
||||||
|
wheeledQ,
|
||||||
|
wheeledRange,
|
||||||
|
} from './eqView';
|
||||||
|
|
||||||
|
/** The least the curve shrinks to; otherwise it takes what its panel leaves it. */
|
||||||
|
const MIN_CURVE_WIDTH = 160;
|
||||||
|
const MIN_CURVE_HEIGHT = 80;
|
||||||
|
const HANDLE_RADIUS = 7;
|
||||||
|
const GRAB_RADIUS = HANDLE_RADIUS + 4;
|
||||||
|
/** Pixels of travel before a press on a band becomes a drag. */
|
||||||
|
const DRAG_THRESHOLD = 3;
|
||||||
|
/** Points the curve is drawn through, evenly in octaves. */
|
||||||
|
const CURVE_STEPS = 320;
|
||||||
|
const BAND_COLORS = [
|
||||||
|
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
||||||
|
];
|
||||||
|
/** Pixels between the points the spectrum is drawn through (#374). */
|
||||||
|
const SPECTRUM_STEP = 2;
|
||||||
|
/** Pixels kept between two labels on an axis; one that comes closer is left out (#385). */
|
||||||
|
const LABEL_GAP = 4;
|
||||||
|
|
||||||
|
/** What the curve asks of the panel around it. */
|
||||||
|
export interface CurveHost {
|
||||||
|
bands(): readonly PassBand[];
|
||||||
|
range(): number;
|
||||||
|
zoom(range: number): void;
|
||||||
|
readonly picked: number;
|
||||||
|
pick(index: number): void;
|
||||||
|
/** A band changed as a drag goes, written into the node for a preview. */
|
||||||
|
drag(index: number, change: Partial<PassBand>): void;
|
||||||
|
/** The drag is over: its undo step. */
|
||||||
|
drop(): void;
|
||||||
|
/** A band changed as an undo step of its own. */
|
||||||
|
set(index: number, change: Partial<PassBand>): void;
|
||||||
|
/** A double-click on the free curve; false when the panel takes no band there. */
|
||||||
|
addBand(frequency: number, gain: number): boolean;
|
||||||
|
/** A right-click on a band; false when the band stays. */
|
||||||
|
removeBand(index: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BandCurve extends Widget {
|
||||||
|
private readonly bg = new Graphics();
|
||||||
|
private readonly grid = new Graphics();
|
||||||
|
/** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */
|
||||||
|
private readonly spectrum = new Graphics();
|
||||||
|
private readonly lines = new Graphics();
|
||||||
|
private readonly handles = new Graphics();
|
||||||
|
private readonly labels: Text[] = [];
|
||||||
|
/** As many as the most lines a range has; those of a range with fewer are hidden. */
|
||||||
|
private readonly dbLabels: Text[] = [];
|
||||||
|
private readonly numbers: Text[] = [];
|
||||||
|
private dragging: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||||||
|
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
|
||||||
|
private heard: Spectrum | null = null;
|
||||||
|
/** The dB drawn while a zoom is on its way (#377); null draws the node's. */
|
||||||
|
private shownRange: number | null = null;
|
||||||
|
private readonly tween = new ViewTween({
|
||||||
|
read: () => ({ scale: 1 / this.range, x: 0, y: 0 }),
|
||||||
|
write: (view) => (this.shownRange = 1 / view.scale),
|
||||||
|
draw: () => {
|
||||||
|
if (!this.tween.moving) this.shownRange = null;
|
||||||
|
this.redraw();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
|
||||||
|
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
|
||||||
|
|
||||||
|
constructor(private readonly host: CurveHost) {
|
||||||
|
super();
|
||||||
|
this.minWidth = MIN_CURVE_WIDTH;
|
||||||
|
this.minHeight = MIN_CURVE_HEIGHT;
|
||||||
|
this.cursor = 'crosshair';
|
||||||
|
this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles);
|
||||||
|
const muted = textStyle('small').clone();
|
||||||
|
muted.fill = Theme.colors.textMuted;
|
||||||
|
const onHandle = textStyle('small').clone();
|
||||||
|
onHandle.fill = Theme.colors.symbolOutline;
|
||||||
|
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
|
||||||
|
const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length));
|
||||||
|
for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted }));
|
||||||
|
for (let i = 0; i < MAX_EQ_BANDS; i++)
|
||||||
|
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle }));
|
||||||
|
for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) {
|
||||||
|
text.eventMode = 'none';
|
||||||
|
this.addChild(text);
|
||||||
|
}
|
||||||
|
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
|
||||||
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
|
||||||
|
this.onPointerRelease(() => this.release());
|
||||||
|
this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
get area(): EqArea {
|
||||||
|
return { w: this.w, h: this.h, range: this.range };
|
||||||
|
}
|
||||||
|
|
||||||
|
private get range(): number {
|
||||||
|
return this.shownRange ?? this.host.range();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dB drawn and whether a zoom is on its way – the check scripts sample it. */
|
||||||
|
get zoomShown(): { range: number; moving: boolean } {
|
||||||
|
return { range: this.range, moving: this.tween.moving };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The axis labels drawn, for the checks: those that would run into another are left out. */
|
||||||
|
get shownLabels(): { frequencies: string[]; dbs: string[] } {
|
||||||
|
const shown = (texts: Text[]) => texts.filter((t) => t.visible).map((t) => t.text);
|
||||||
|
return { frequencies: shown(this.labels), dbs: shown(this.dbLabels) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another node: a zoom on its way to the one before ends. */
|
||||||
|
settle(): void {
|
||||||
|
this.tween.land();
|
||||||
|
this.shownRange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a band's handle is on screen – the check scripts aim with it. */
|
||||||
|
handleGlobal(index: number): { x: number; y: number } | null {
|
||||||
|
const band = this.host.bands()[index];
|
||||||
|
if (!band) return null;
|
||||||
|
const at = this.toGlobal(handleOf(band, this.area));
|
||||||
|
return { x: at.x, y: at.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a frequency and a gain are on screen, at the graph's zoom in the node. */
|
||||||
|
pointGlobal(frequency: number, db: number): { x: number; y: number } {
|
||||||
|
const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) });
|
||||||
|
return { x: at.x, y: at.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private local(e: FederatedPointerEvent): { x: number; y: number } {
|
||||||
|
return this.toLocal(e.global);
|
||||||
|
}
|
||||||
|
|
||||||
|
private press(e: FederatedPointerEvent): void {
|
||||||
|
const at = this.local(e);
|
||||||
|
const bands = this.host.bands();
|
||||||
|
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||||
|
if (e.button === 2) {
|
||||||
|
if (index >= 0 && this.host.removeBand(index)) e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
// In the node a press on the curve is the curve's: it neither picks nor moves the node.
|
||||||
|
e.stopPropagation();
|
||||||
|
if (index >= 0) {
|
||||||
|
this.host.pick(index);
|
||||||
|
this.dragging = { index, startX: e.global.x, startY: e.global.y, moved: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A double-click on the free curve may put a band there; a single one lets go of the picked one.
|
||||||
|
if (pressCount(e, this) === 2) {
|
||||||
|
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||||||
|
if (this.host.addBand(frequency, Math.round(yToDb(at.y, this.area) * 10) / 10)) return;
|
||||||
|
}
|
||||||
|
this.host.pick(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private move(e: FederatedPointerEvent): void {
|
||||||
|
const drag = this.dragging;
|
||||||
|
if (!drag) return;
|
||||||
|
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
|
drag.moved = true;
|
||||||
|
const at = this.local(e);
|
||||||
|
const band = this.host.bands()[drag.index];
|
||||||
|
if (!band) return;
|
||||||
|
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||||||
|
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
|
||||||
|
this.host.drag(drag.index, hasGain(band.type) ? { frequency, gain } : { frequency });
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(): void {
|
||||||
|
const drag = this.dragging;
|
||||||
|
this.dragging = null;
|
||||||
|
if (drag?.moved) this.host.drop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in
|
||||||
|
* the tab: in the node the graph takes every wheel before the curve sees it (#383).
|
||||||
|
*/
|
||||||
|
private wheel(e: FederatedWheelEvent): void {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
const at = this.toLocal(e.global);
|
||||||
|
const bands = this.host.bands();
|
||||||
|
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||||
|
const band = bands[index];
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!band) {
|
||||||
|
const range = wheeledRange(this.host.range(), e.deltaY);
|
||||||
|
this.tween.change(() => {
|
||||||
|
this.shownRange = null;
|
||||||
|
this.host.zoom(range);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasQ(band.type)) return;
|
||||||
|
this.host.pick(index);
|
||||||
|
this.host.set(index, { q: wheeledQ(band.q, e.deltaY) });
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
const area = this.area;
|
||||||
|
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
|
||||||
|
|
||||||
|
this.grid.clear();
|
||||||
|
const frequencySpans = EQ_GRID_FREQUENCIES.map(({ hz }, i) => {
|
||||||
|
const x = Math.round(frequencyToX(hz, area)) + 0.5;
|
||||||
|
this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||||||
|
const text = this.labels[i]!;
|
||||||
|
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
|
||||||
|
return { from: text.x, to: text.x + text.width };
|
||||||
|
});
|
||||||
|
apartLabels(frequencySpans, LABEL_GAP).forEach((shown, i) => (this.labels[i]!.visible = shown));
|
||||||
|
// The lines of the range a zoom is heading to, so they slide to their place with whole numbers.
|
||||||
|
const dbs = eqGridDb(this.host.range());
|
||||||
|
const dbSpans: { from: number; to: number }[] = [];
|
||||||
|
const placed: Text[] = [];
|
||||||
|
this.dbLabels.forEach((text, i) => {
|
||||||
|
const db = dbs[i];
|
||||||
|
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
|
||||||
|
text.visible = y > 0 && y < this.h;
|
||||||
|
if (!text.visible || db === undefined) return;
|
||||||
|
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||||
|
text.text = db > 0 ? `+${db}` : `${db}`;
|
||||||
|
text.position.set(3, y - text.height - 1);
|
||||||
|
dbSpans.push({ from: text.y, to: text.y + text.height });
|
||||||
|
placed.push(text);
|
||||||
|
});
|
||||||
|
apartLabels(dbSpans, 0).forEach((shown, i) => (placed[i]!.visible = shown));
|
||||||
|
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
|
||||||
|
const zero = Math.round(dbToY(0, area)) + 0.5;
|
||||||
|
this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 });
|
||||||
|
|
||||||
|
const bands = this.host.bands();
|
||||||
|
this.lines.clear();
|
||||||
|
const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2);
|
||||||
|
const xs = curves.xs;
|
||||||
|
curves.bands.forEach((ys, i) => {
|
||||||
|
trace(this.lines, xs, ys);
|
||||||
|
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||||||
|
});
|
||||||
|
for (const shape of fillShapes(xs, curves.sum, zero))
|
||||||
|
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||||||
|
trace(this.lines, xs, curves.sum);
|
||||||
|
this.lines.stroke({ color: c.accent, width: 2 });
|
||||||
|
|
||||||
|
this.handles.clear();
|
||||||
|
this.numbers.forEach((text, i) => {
|
||||||
|
const band = bands[i];
|
||||||
|
text.visible = Boolean(band);
|
||||||
|
if (!band) return;
|
||||||
|
const at = handleOf(band, area);
|
||||||
|
this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]);
|
||||||
|
if (i === this.host.picked)
|
||||||
|
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
|
||||||
|
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
|
||||||
|
});
|
||||||
|
this.drawSpectrum();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */
|
||||||
|
showSpectrum(spectrum: Spectrum | null): void {
|
||||||
|
const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz);
|
||||||
|
const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz);
|
||||||
|
const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB;
|
||||||
|
const shown = loud ? spectrum : null;
|
||||||
|
if (!shown && !this.heard) return;
|
||||||
|
this.heard = shown;
|
||||||
|
if (shown)
|
||||||
|
this.seen = {
|
||||||
|
frames: this.seen.frames + 1,
|
||||||
|
peakIn: peakIn?.hz ?? 0,
|
||||||
|
peakOut: peakOut?.hz ?? 0,
|
||||||
|
levelOut: peakOut?.db ?? -Infinity,
|
||||||
|
binHz: shown.binHz,
|
||||||
|
};
|
||||||
|
this.drawSpectrum();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasSpectrum(): boolean {
|
||||||
|
return this.heard !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSpectrum(): void {
|
||||||
|
this.spectrum.clear();
|
||||||
|
const heard = this.heard;
|
||||||
|
if (!heard) return;
|
||||||
|
const c = Theme.colors;
|
||||||
|
const count = Math.floor(this.w / SPECTRUM_STEP);
|
||||||
|
const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w);
|
||||||
|
for (const [dbs, color, alpha, line] of [
|
||||||
|
[heard.in, c.textMuted, 0.12, false],
|
||||||
|
[heard.out, c.portAudio, 0.18, true],
|
||||||
|
] as const) {
|
||||||
|
const ys = spectrumYs(dbs, heard.binHz, xs, this.area);
|
||||||
|
this.spectrum
|
||||||
|
.poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h])
|
||||||
|
.fill({ color, alpha });
|
||||||
|
if (!line) continue;
|
||||||
|
trace(this.spectrum, xs, ys);
|
||||||
|
this.spectrum.stroke({ color, width: 1, alpha: 0.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path through the points. */
|
||||||
|
function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void {
|
||||||
|
g.moveTo(xs[0]!, ys[0]!);
|
||||||
|
for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!);
|
||||||
|
}
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
import { tapId } from '../../core/soundfx/nodes';
|
import { tapId } from '../../core/soundfx/nodes';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import type { NodeFace } from '../graph/nodeFace';
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
import type { EditorState } from '../state';
|
import { CURVE_NODE_MIN, type CurvePanel } from './CurvePanel';
|
||||||
import { EqPanel } from './EqPanel';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Parametric EQ node drawn as its editor (#381): the panel of its tab, worked right in the graph,
|
* A Parametric EQ node (#381) or a Filter node (#385) drawn as its editor: the panel of its tab,
|
||||||
* with the spectrum behind the curve while the node is in the picture.
|
* worked right in the graph, with the spectrum behind the curve while the node is in the picture.
|
||||||
|
* It can be dragged to another size (#385).
|
||||||
*/
|
*/
|
||||||
export class EqFace implements NodeFace {
|
export class CurveFace implements NodeFace {
|
||||||
readonly widget: EqPanel;
|
readonly minSize = CURVE_NODE_MIN;
|
||||||
private listening = false;
|
private listening = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
state: EditorState,
|
readonly widget: CurvePanel,
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly nodeId: string,
|
private readonly nodeId: string,
|
||||||
) {
|
) {}
|
||||||
this.widget = new EqPanel(state, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
sync(): void {
|
sync(): void {
|
||||||
this.widget.show(this.assetId, this.nodeId);
|
this.widget.show(this.assetId, this.nodeId);
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* What the editors of a Parametric EQ (#367) and of a Filter (#385) share: the curve, the numbers of
|
||||||
|
* the picked band in a row under it and, in a tab, a line of help. A tab shows the panel, and so
|
||||||
|
* does the node in the graph (#381), at the size the node was dragged to (#385).
|
||||||
|
*
|
||||||
|
* Too little room leaves things out rather than cutting them (#385): the row comes in only once the
|
||||||
|
* curve keeps `CURVE_ROOM` above it, the help only once the row has come in too, and a group of the
|
||||||
|
* row that does not fit is not drawn – the one on the right goes first.
|
||||||
|
*/
|
||||||
|
import type { FederatedPointerEvent } from 'pixi.js';
|
||||||
|
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import type { FxPassFilter } from '../../core/soundfx/nodes';
|
||||||
|
import {
|
||||||
|
EQ_FREQUENCY,
|
||||||
|
EQ_Q,
|
||||||
|
EQ_RANGE_PARAM,
|
||||||
|
EQ_SLOPES,
|
||||||
|
eqRangeOf,
|
||||||
|
hasGain,
|
||||||
|
hasQ,
|
||||||
|
hasSlope,
|
||||||
|
type PassBand,
|
||||||
|
} from '../../core/soundfx/parametricEq';
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
HStack,
|
||||||
|
Label,
|
||||||
|
Menu,
|
||||||
|
NumberField,
|
||||||
|
type Size,
|
||||||
|
Spacer,
|
||||||
|
Theme,
|
||||||
|
VStack,
|
||||||
|
Widget,
|
||||||
|
} from '../../ui';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { BandCurve, type CurveHost } from './BandCurve';
|
||||||
|
import { fittingCount, shownRows } from './eqView';
|
||||||
|
|
||||||
|
/** The height the curve keeps before the row under it comes in (#385). */
|
||||||
|
export const CURVE_ROOM = 120;
|
||||||
|
/** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */
|
||||||
|
export const CURVE_NODE_MIN: Size = { w: 240, h: 120 };
|
||||||
|
|
||||||
|
/** A row of groups, drawn from the left as far as they fit; the rest are not drawn at all (#385). */
|
||||||
|
class FittingRow extends HStack {
|
||||||
|
private readonly names: string[] = [];
|
||||||
|
|
||||||
|
private groups(): Widget[] {
|
||||||
|
return this.children.filter((c): c is Widget => c instanceof Widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
addGroup(name: string, group: HStack): HStack {
|
||||||
|
this.names.push(name);
|
||||||
|
return this.add(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): Size {
|
||||||
|
const sizes = this.groups().map((group) => group.measure());
|
||||||
|
const w = sizes.reduce((sum, size) => sum + size.w, 0) + Math.max(0, sizes.length - 1) * this.gap;
|
||||||
|
const h = Math.max(0, ...sizes.map((size) => size.h));
|
||||||
|
return {
|
||||||
|
w: Math.max(this.minWidth, w + this.padding * 2),
|
||||||
|
h: Math.max(this.minHeight, h + this.padding * 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
const groups = this.groups();
|
||||||
|
const fit = fittingCount(
|
||||||
|
groups.map((group) => group.measure().w),
|
||||||
|
this.gap,
|
||||||
|
this.w - this.padding * 2,
|
||||||
|
);
|
||||||
|
groups.forEach((group, i) => (group.visible = i < fit));
|
||||||
|
super.layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The groups drawn, by name – the check scripts read it. */
|
||||||
|
get shown(): string[] {
|
||||||
|
return this.names.filter((_, i) => this.groups()[i]?.visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurvePanelOptions {
|
||||||
|
/** What the panel is at in a node until the node is dragged to another size. */
|
||||||
|
nodeSize: Size;
|
||||||
|
types: readonly { value: FxPassFilter; label: string }[];
|
||||||
|
gain: { min: number; max: number };
|
||||||
|
help: string;
|
||||||
|
/** A group before the type, and one at the right end of the row. */
|
||||||
|
lead?: Widget;
|
||||||
|
end?: Widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class CurvePanel extends VStack implements CurveHost {
|
||||||
|
readonly curve: BandCurve;
|
||||||
|
protected assetId = '';
|
||||||
|
protected nodeId = '';
|
||||||
|
picked = -1;
|
||||||
|
readonly typeButton: Dropdown;
|
||||||
|
readonly slopeButton: Dropdown;
|
||||||
|
readonly frequencyField: NumberField;
|
||||||
|
readonly gainField: NumberField;
|
||||||
|
readonly qField: NumberField;
|
||||||
|
protected readonly row = new FittingRow({ gap: Theme.gap, align: 'center' });
|
||||||
|
private readonly help: Label | null;
|
||||||
|
/** What the node held before a drag or a scrub began – its undo step's other side. */
|
||||||
|
private readonly before = new Map<string, PropertyValue | undefined>();
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly state: EditorState,
|
||||||
|
readonly inNode: boolean,
|
||||||
|
private readonly options: CurvePanelOptions,
|
||||||
|
frameName: string,
|
||||||
|
) {
|
||||||
|
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
|
||||||
|
this.perFrame = new FramePreview(state, frameName);
|
||||||
|
this.curve = new BandCurve(this);
|
||||||
|
const numbers = (change: (value: number) => Partial<PassBand>) => ({
|
||||||
|
onInput: (value: number) => this.drag(this.picked, change(value)),
|
||||||
|
onChange: (value: number) => this.set(this.picked, change(value)),
|
||||||
|
});
|
||||||
|
this.typeButton = new Dropdown('—', { width: 104, onClick: () => this.openTypeMenu() });
|
||||||
|
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
|
||||||
|
this.frequencyField = new NumberField(1000, {
|
||||||
|
width: 64,
|
||||||
|
min: EQ_FREQUENCY.min,
|
||||||
|
max: EQ_FREQUENCY.max,
|
||||||
|
step: 1,
|
||||||
|
decimals: 0,
|
||||||
|
...numbers((frequency) => ({ frequency })),
|
||||||
|
});
|
||||||
|
this.gainField = new NumberField(0, {
|
||||||
|
width: 52,
|
||||||
|
min: options.gain.min,
|
||||||
|
max: options.gain.max,
|
||||||
|
step: 0.1,
|
||||||
|
decimals: 1,
|
||||||
|
...numbers((gain) => ({ gain })),
|
||||||
|
});
|
||||||
|
this.qField = new NumberField(1, {
|
||||||
|
width: 52,
|
||||||
|
min: EQ_Q.min,
|
||||||
|
max: EQ_Q.max,
|
||||||
|
step: 0.01,
|
||||||
|
decimals: 3,
|
||||||
|
...numbers((q) => ({ q })),
|
||||||
|
});
|
||||||
|
// In the node a press on a menu button is not one on the node: it neither picks nor moves it.
|
||||||
|
if (inNode)
|
||||||
|
for (const button of [this.typeButton, this.slopeButton])
|
||||||
|
button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||||||
|
if (options.lead) this.group('Band', options.lead);
|
||||||
|
for (const [name, widget] of [
|
||||||
|
['Type', this.typeButton],
|
||||||
|
['Hz', this.frequencyField],
|
||||||
|
['dB', this.gainField],
|
||||||
|
['Q', this.qField],
|
||||||
|
['Slope', this.slopeButton],
|
||||||
|
] as const)
|
||||||
|
this.group(name, new Label(name, { style: 'small' }), widget);
|
||||||
|
if (options.end) {
|
||||||
|
// The last group takes what is left, so what it holds sits at the right end.
|
||||||
|
const spacer = new Spacer();
|
||||||
|
const end = this.group('End', spacer, options.end);
|
||||||
|
end.flex = 1;
|
||||||
|
spacer.flex = 1;
|
||||||
|
}
|
||||||
|
this.help = inNode ? null : new Label(options.help, { style: 'muted' });
|
||||||
|
this.add(this.curve, 1);
|
||||||
|
this.add(this.row);
|
||||||
|
if (this.help) this.add(this.help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of the row: a name and what it names, drawn or left out together. */
|
||||||
|
private group(name: string, ...widgets: Widget[]): HStack {
|
||||||
|
const group = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
for (const widget of widgets) group.add(widget);
|
||||||
|
return this.row.addGroup(name, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): Size {
|
||||||
|
return this.inNode ? this.options.nodeSize : super.measure();
|
||||||
|
}
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
const rows = this.help ? [this.row, this.help] : [this.row];
|
||||||
|
const shown = shownRows(
|
||||||
|
this.h - this.padding * 2,
|
||||||
|
CURVE_ROOM,
|
||||||
|
rows.map((row) => row.measure().h),
|
||||||
|
this.gap,
|
||||||
|
);
|
||||||
|
rows.forEach((row, i) => (row.visible = i < shown));
|
||||||
|
super.layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the panel draws under the curve, for the checks: the groups of the row and the help. */
|
||||||
|
get shownRows(): { row: string[]; help: boolean } {
|
||||||
|
return { row: this.row.visible ? this.row.shown : [], help: this.help?.visible ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Points the panel at a node; what was picked stays when it is the same node again. */
|
||||||
|
show(assetId: string, nodeId: string): void {
|
||||||
|
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||||
|
this.curve.settle();
|
||||||
|
this.perFrame.cancel();
|
||||||
|
this.curve.showSpectrum(null);
|
||||||
|
this.before.clear();
|
||||||
|
this.assetId = assetId;
|
||||||
|
this.nodeId = nodeId;
|
||||||
|
this.picked = this.bands().length > 0 ? 0 : -1;
|
||||||
|
}
|
||||||
|
this.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel lets go of its node. */
|
||||||
|
close(): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
|
this.curve.showSpectrum(null);
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.restore(node);
|
||||||
|
this.assetId = '';
|
||||||
|
this.nodeId = '';
|
||||||
|
this.picked = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get shownFor(): { assetId: string; nodeId: string } | null {
|
||||||
|
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract node(): GraphNode | null;
|
||||||
|
abstract bands(): PassBand[];
|
||||||
|
abstract drag(index: number, change: Partial<PassBand>): void;
|
||||||
|
abstract drop(): void;
|
||||||
|
abstract set(index: number, change: Partial<PassBand>): void;
|
||||||
|
|
||||||
|
addBand(_frequency: number, _gain: number): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBand(_index: number): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which of the picked band's numbers are fed by a link and cannot be typed or dragged. */
|
||||||
|
protected locked(): { frequency: boolean; gain: boolean; q: boolean } {
|
||||||
|
return { frequency: false, gain: false, q: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
range(): number {
|
||||||
|
return eqRangeOf(this.node()?.params ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
zoom(range: number): void {
|
||||||
|
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(index: number): void {
|
||||||
|
if (this.picked === index) return;
|
||||||
|
this.picked = index;
|
||||||
|
this.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes values into the node as they are dragged; `commit` makes the undo step of them. */
|
||||||
|
protected preview(values: Record<string, PropertyValue>): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (!this.before.has(key)) this.before.set(key, node.params[key]);
|
||||||
|
node.params[key] = value;
|
||||||
|
}
|
||||||
|
this.perFrame.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The values as one undo step, after what a drag wrote is put back; none when nothing changes. */
|
||||||
|
protected commit(values: Record<string, PropertyValue>, label: string): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
this.restore(node);
|
||||||
|
const commands = Object.entries(values)
|
||||||
|
.filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(node.params[key]))
|
||||||
|
.map(([key, value]) => new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
|
||||||
|
if (commands.length === 0) this.state.preview();
|
||||||
|
else this.state.execute(oneStep(label, commands));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a drag wrote as one undo step. */
|
||||||
|
protected commitPreview(label: string): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
const values: Record<string, PropertyValue> = {};
|
||||||
|
for (const key of this.before.keys()) if (node.params[key] !== undefined) values[key] = node.params[key];
|
||||||
|
this.commit(values, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restore(node: GraphNode): void {
|
||||||
|
for (const [key, value] of this.before) {
|
||||||
|
if (value === undefined) delete node.params[key];
|
||||||
|
else node.params[key] = value;
|
||||||
|
}
|
||||||
|
this.before.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openTypeMenu(): void {
|
||||||
|
const band = this.bands()[this.picked];
|
||||||
|
if (!band) return;
|
||||||
|
const items = this.options.types.map((type) => ({
|
||||||
|
label: type.label,
|
||||||
|
current: type.value === band.type,
|
||||||
|
onSelect: () => this.set(this.picked, { type: type.value }),
|
||||||
|
}));
|
||||||
|
new Menu(items).openBelow(this.typeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openSlopeMenu(): void {
|
||||||
|
const band = this.bands()[this.picked];
|
||||||
|
if (!band) return;
|
||||||
|
const items = EQ_SLOPES.map((slope) => ({
|
||||||
|
label: `${slope} dB/oct`,
|
||||||
|
current: slope === band.slope,
|
||||||
|
onSelect: () => this.set(this.picked, { slope }),
|
||||||
|
}));
|
||||||
|
new Menu(items).openBelow(this.slopeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After every change to the model: the row shows the picked band as it now is. */
|
||||||
|
sync(): void {
|
||||||
|
if (!this.node()) return;
|
||||||
|
const bands = this.bands();
|
||||||
|
if (this.picked >= bands.length) this.picked = -1;
|
||||||
|
const band = bands[this.picked];
|
||||||
|
const locked = this.locked();
|
||||||
|
this.typeButton.setText(this.options.types.find((t) => t.value === band?.type)?.label ?? '—');
|
||||||
|
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
|
||||||
|
this.frequencyField.setNumber(band?.frequency ?? 1000);
|
||||||
|
this.gainField.setNumber(band?.gain ?? 0);
|
||||||
|
this.qField.setNumber(band?.q ?? 1);
|
||||||
|
this.typeButton.setEnabled(Boolean(band));
|
||||||
|
this.frequencyField.setEnabled(Boolean(band && !locked.frequency));
|
||||||
|
this.gainField.setEnabled(Boolean(band && hasGain(band.type) && !locked.gain));
|
||||||
|
this.qField.setEnabled(Boolean(band && hasQ(band.type) && !locked.q));
|
||||||
|
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
|
||||||
|
this.curve.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* The tab of a Parametric EQ node (#370): the panel of `EqPanel.ts` under a header, pointed at the
|
* The tab of a Parametric EQ node (#370) or a Filter node (#385): its panel under a header, pointed
|
||||||
* node whose tab is showing. While the tab is seen, the spectrum of what passes the node is drawn
|
* at the node whose tab is showing. While the tab is seen, the spectrum of what passes the node is
|
||||||
* behind the curve (#374).
|
* drawn behind the curve (#374, #385).
|
||||||
*/
|
*/
|
||||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
|
import { SoundFxFilter, tapId } from '../../core/soundfx/nodes';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import { Label, Spacer, VStack } from '../../ui';
|
import { Label, Spacer, VStack } from '../../ui';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
|
import type { CurvePanel } from './CurvePanel';
|
||||||
import { EqPanel } from './EqPanel';
|
import { EqPanel } from './EqPanel';
|
||||||
|
import { FilterPanel } from './FilterPanel';
|
||||||
|
|
||||||
|
const TITLES = { eq: 'Parametric EQ', filter: 'Filter' } as const;
|
||||||
|
|
||||||
/** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */
|
/** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */
|
||||||
export class EqEditor extends VStack {
|
export class EqEditor extends VStack {
|
||||||
readonly panel: EqPanel;
|
readonly panel: CurvePanel;
|
||||||
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
|
private readonly title: Label;
|
||||||
private readonly header = new TabHeader();
|
private readonly header = new TabHeader();
|
||||||
/** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */
|
/** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */
|
||||||
private listening = false;
|
private listening = false;
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(
|
||||||
|
private readonly state: EditorState,
|
||||||
|
private readonly kind: 'eq' | 'filter' = 'eq',
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.panel = new EqPanel(state);
|
this.panel = kind === 'eq' ? new EqPanel(state) : new FilterPanel(state);
|
||||||
|
this.title = new Label(TITLES[kind], { style: 'bodyBold' });
|
||||||
this.header.add(this.title);
|
this.header.add(this.title);
|
||||||
this.header.add(new Spacer(), 1);
|
this.header.add(new Spacer(), 1);
|
||||||
this.add(this.header);
|
this.add(this.header);
|
||||||
@@ -69,16 +77,16 @@ export class EqEditor extends VStack {
|
|||||||
return this.panel.curve.hasSpectrum;
|
return this.panel.curve.hasSpectrum;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete in the tab takes the picked band – never the node the tab is about. */
|
/** Delete in an EQ's tab takes the picked band – never the node the tab is about. */
|
||||||
removePicked(): void {
|
removePicked(): void {
|
||||||
this.panel.removePicked();
|
if (this.panel instanceof EqPanel) this.panel.removePicked();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sync(): void {
|
private sync(): void {
|
||||||
const shown = this.panel.shownFor;
|
const shown = this.panel.shownFor;
|
||||||
if (!shown) return;
|
if (!shown) return;
|
||||||
this.panel.sync();
|
this.panel.sync();
|
||||||
const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
|
const title = `${TITLES[this.kind]} – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`;
|
||||||
if (title === this.title.text) return;
|
if (title === this.title.text) return;
|
||||||
this.title.setText(title);
|
this.title.setText(title);
|
||||||
this.relayout();
|
this.relayout();
|
||||||
@@ -86,12 +94,15 @@ export class EqEditor extends VStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
|
* What an EQ's or a Filter's tab says: its asset's name, and which one it is when the asset has
|
||||||
|
* more than one of them.
|
||||||
*/
|
*/
|
||||||
export function eqTabLabel(project: Project, assetId: string, nodeId: string): string {
|
export function eqTabLabel(project: Project, assetId: string, nodeId: string): string {
|
||||||
const asset = findGraphAsset(project, assetId);
|
const asset = findGraphAsset(project, assetId);
|
||||||
if (!asset) return '';
|
const type = asset?.graph.nodes.find((n) => n.id === nodeId)?.type;
|
||||||
const eqs = asset.graph.nodes.filter((n) => n.type === SoundFxParametricEq.type);
|
if (!asset || !type) return '';
|
||||||
const index = eqs.findIndex((n) => n.id === nodeId);
|
const noun = type === SoundFxFilter.type ? 'Filter' : 'EQ';
|
||||||
return eqs.length > 1 ? `${asset.name} · EQ ${index + 1}` : `${asset.name} · EQ`;
|
const same = asset.graph.nodes.filter((n) => n.type === type);
|
||||||
|
const index = same.findIndex((n) => n.id === nodeId);
|
||||||
|
return same.length > 1 ? `${asset.name} · ${noun} ${index + 1}` : `${asset.name} · ${noun}`;
|
||||||
}
|
}
|
||||||
+65
-568
@@ -1,506 +1,58 @@
|
|||||||
/**
|
/**
|
||||||
* The editor of a Parametric EQ node (#367): the curve and the numbers of the picked band. Its tab
|
* The editor of a Parametric EQ node (#367): the curve of its bands and the numbers of the picked
|
||||||
* (#370) shows it, and since #381 so does the node itself in the graph. The curve is the sum of the
|
* one. Its tab (#370) shows it, and since #381 so does the node itself in the graph. A double-click
|
||||||
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
* on the free curve adds a band, a right-click on one removes it. While it is seen, the spectrum of
|
||||||
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
* what passes the node is drawn behind the curve (#374).
|
||||||
* 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 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 { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import {
|
import {
|
||||||
dbToY,
|
|
||||||
type EqArea,
|
|
||||||
type EqBand,
|
type EqBand,
|
||||||
EQ_BAND_TYPES,
|
EQ_BAND_TYPES,
|
||||||
EQ_BANDS_PARAM,
|
EQ_BANDS_PARAM,
|
||||||
EQ_FREQUENCY,
|
|
||||||
EQ_GAIN,
|
EQ_GAIN,
|
||||||
EQ_Q,
|
|
||||||
EQ_RANGE_PARAM,
|
|
||||||
EQ_RANGES,
|
|
||||||
EQ_SLOPES,
|
|
||||||
eqBandsOf,
|
eqBandsOf,
|
||||||
eqBandsValue,
|
eqBandsValue,
|
||||||
eqCurvePoints,
|
|
||||||
eqRangeOf,
|
|
||||||
fillShapes,
|
|
||||||
frequencyToX,
|
|
||||||
hasGain,
|
|
||||||
hasQ,
|
|
||||||
hasSlope,
|
|
||||||
MAX_EQ_BANDS,
|
MAX_EQ_BANDS,
|
||||||
newEqBand,
|
newEqBand,
|
||||||
|
type PassBand,
|
||||||
withBandSet,
|
withBandSet,
|
||||||
xToFrequency,
|
|
||||||
yToDb,
|
|
||||||
} from '../../core/soundfx/parametricEq';
|
} from '../../core/soundfx/parametricEq';
|
||||||
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
import { Label } from '../../ui';
|
||||||
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 type { EditorState } from '../state';
|
||||||
import { ViewTween } from '../viewTween';
|
import { CurvePanel } from './CurvePanel';
|
||||||
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. */
|
/** The panel in the node (#381): a little narrower than the Compressor's, and taller. */
|
||||||
export const EQ_NODE_SIZE = { w: 720, h: 360 };
|
export const EQ_NODE_SIZE = { w: 720, h: 360 };
|
||||||
|
|
||||||
/** What the curve asks of the panel around it. */
|
export class EqPanel extends CurvePanel {
|
||||||
interface EqHost {
|
private readonly bandLabel: Label;
|
||||||
bands(): EqBand[];
|
private readonly count: Label;
|
||||||
range(): number;
|
/** The texts the row was last laid out for; a drag changes only numbers and needs no layout. */
|
||||||
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 = '';
|
private laidOut = '';
|
||||||
|
|
||||||
constructor(
|
constructor(state: EditorState, inNode = false) {
|
||||||
private readonly state: EditorState,
|
const bandLabel = new Label('', { style: 'small' });
|
||||||
readonly inNode = false,
|
const count = new Label('', { style: 'muted' });
|
||||||
) {
|
super(
|
||||||
super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 });
|
state,
|
||||||
this.perFrame = new FramePreview(state, inNode ? 'eq-node' : 'eq-band');
|
inNode,
|
||||||
this.curve = new EqCurve(this);
|
{
|
||||||
|
nodeSize: EQ_NODE_SIZE,
|
||||||
const numbers = (change: (value: number) => Partial<EqBand>) => ({
|
types: EQ_BAND_TYPES,
|
||||||
onInput: (value: number) => this.previewPicked(change(value)),
|
gain: EQ_GAIN,
|
||||||
onChange: (value: number) => this.commitPicked(change(value)),
|
help: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.',
|
||||||
});
|
lead: bandLabel,
|
||||||
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
|
end: count,
|
||||||
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
|
},
|
||||||
this.frequencyField = new NumberField(1000, {
|
inNode ? 'eq-node' : 'eq-band',
|
||||||
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' },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
this.bandLabel = bandLabel;
|
||||||
|
this.count = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
override measure(): { w: number; h: number } {
|
protected node(): GraphNode | null {
|
||||||
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);
|
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,119 +61,64 @@ export class EqPanel extends VStack implements EqHost {
|
|||||||
return node ? eqBandsOf(node.params) : [];
|
return node ? eqBandsOf(node.params) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
range(): number {
|
/** A band's values as `withBandSet` holds them: a shape the EQ has not, like an all-pass, is none. */
|
||||||
return eqRangeOf(this.node()?.params ?? {});
|
private changed(index: number, change: Partial<PassBand>): EqBand[] {
|
||||||
|
return withBandSet(this.bands(), index, change as Partial<EqBand>);
|
||||||
}
|
}
|
||||||
|
|
||||||
zoom(range: number): void {
|
private commitBands(bands: EqBand[]): void {
|
||||||
this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range);
|
this.commit({ [EQ_BANDS_PARAM]: eqBandsValue(bands) }, 'Set bands');
|
||||||
}
|
}
|
||||||
|
|
||||||
pick(index: number): void {
|
drag(index: number, change: Partial<PassBand>): void {
|
||||||
if (this.picked === index) return;
|
if (index >= 0) this.preview({ [EQ_BANDS_PARAM]: eqBandsValue(this.changed(index, change)) });
|
||||||
this.picked = index;
|
|
||||||
this.sync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
drop(): void {
|
||||||
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
|
this.commitPreview('Set bands');
|
||||||
* 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 {
|
set(index: number, change: Partial<PassBand>): void {
|
||||||
this.perFrame.cancel();
|
if (index >= 0) this.commitBands(this.changed(index, change));
|
||||||
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 {
|
override addBand(frequency: number, gain: number): boolean {
|
||||||
if (this.before === null) return;
|
const bands = this.bands();
|
||||||
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
|
if (bands.length >= MAX_EQ_BANDS) return false;
|
||||||
else node.params[EQ_BANDS_PARAM] = this.before;
|
this.commitBands([...bands, newEqBand(frequency, gain)]);
|
||||||
this.before = null;
|
this.pick(bands.length);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private previewPicked(change: Partial<EqBand>): void {
|
override removeBand(index: number): boolean {
|
||||||
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
|
this.pick(-1);
|
||||||
}
|
this.commitBands(this.bands().filter((_, i) => i !== index));
|
||||||
|
return true;
|
||||||
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;
|
if (this.picked >= 0) this.removeBand(this.picked);
|
||||||
if (index < 0) return;
|
|
||||||
this.picked = -1;
|
|
||||||
this.commit(this.bands().filter((_, i) => i !== index));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private openTypeMenu(): void {
|
override close(): void {
|
||||||
const band = this.bands()[this.picked];
|
super.close();
|
||||||
if (!band) return;
|
this.laidOut = '';
|
||||||
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 {
|
override sync(): void {
|
||||||
const band = this.bands()[this.picked];
|
if (!this.node()) return;
|
||||||
if (!band) return;
|
super.sync();
|
||||||
const items = EQ_SLOPES.map((slope) => ({
|
const count = `${this.bands().length} / ${MAX_EQ_BANDS} bands`;
|
||||||
label: `${slope} dB/oct`,
|
const bandLabel = this.picked >= 0 ? `Band ${this.picked + 1}` : 'No band';
|
||||||
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.count.setText(count);
|
||||||
this.bandLabel.setText(bandLabel);
|
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');
|
const texts = [count, bandLabel].join('\n');
|
||||||
if (texts !== this.laidOut) {
|
if (texts === this.laidOut) return;
|
||||||
this.laidOut = texts;
|
this.laidOut = texts;
|
||||||
this.relayout();
|
this.relayout();
|
||||||
// Its own size stays, so the row would not lay its labels out anew by itself.
|
// Its own size stays, so the row would not lay its labels out anew by itself.
|
||||||
this.row.relayout();
|
this.row.relayout();
|
||||||
}
|
|
||||||
this.curve.invalidate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* The editor of a Filter node (#385): the curve of the Parametric EQ with the Filter as its one band,
|
||||||
|
* and its numbers under it. Its tab shows it, and so does the node itself in the graph. A number fed
|
||||||
|
* by a link is the link's: it can be neither typed nor dragged here.
|
||||||
|
*/
|
||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import {
|
||||||
|
FILTER_ROWS,
|
||||||
|
FILTER_SETTINGS,
|
||||||
|
FILTER_TYPES,
|
||||||
|
filterBandOf,
|
||||||
|
SoundFxFilter,
|
||||||
|
} from '../../core/soundfx/nodes';
|
||||||
|
import type { PassBand } from '../../core/soundfx/parametricEq';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { compressorWired } from './CompressorEditor';
|
||||||
|
import { CurvePanel } from './CurvePanel';
|
||||||
|
|
||||||
|
/** The panel in the node until it is dragged to another size: room for the whole row. */
|
||||||
|
export const FILTER_NODE_SIZE = { w: 560, h: 280 };
|
||||||
|
|
||||||
|
const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 };
|
||||||
|
|
||||||
|
/** A number on its step, within its row. */
|
||||||
|
function held(value: number, row: { min?: number; max?: number }, step: number): number {
|
||||||
|
const per = Math.round(1 / step);
|
||||||
|
return Math.max(row.min ?? value, Math.min(row.max ?? value, Math.round(value * per) / per));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FilterPanel extends CurvePanel {
|
||||||
|
constructor(state: EditorState, inNode = false) {
|
||||||
|
super(
|
||||||
|
state,
|
||||||
|
inNode,
|
||||||
|
{
|
||||||
|
nodeSize: FILTER_NODE_SIZE,
|
||||||
|
types: FILTER_TYPES,
|
||||||
|
gain: GAIN,
|
||||||
|
help: 'Drag: frequency, gain. Wheel: Q on the handle, else zoom.',
|
||||||
|
},
|
||||||
|
inNode ? 'filter-node' : 'filter-band',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected node(): GraphNode | null {
|
||||||
|
return filterNodeOf(this.state.project, this.assetId, this.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bands(): PassBand[] {
|
||||||
|
const node = this.node();
|
||||||
|
return node ? [filterBandOf(node.params)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its one band stays picked. */
|
||||||
|
override pick(): void {}
|
||||||
|
|
||||||
|
protected override locked(): { frequency: boolean; gain: boolean; q: boolean } {
|
||||||
|
const wired = (key: string) => compressorWired(this.state.project, this.assetId, this.nodeId, key);
|
||||||
|
return {
|
||||||
|
frequency: wired(FILTER_ROWS.cutoff.key),
|
||||||
|
gain: wired(FILTER_ROWS.gain.key),
|
||||||
|
q: wired(FILTER_ROWS.q.key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change of the band as what the node holds, without the numbers a link feeds. */
|
||||||
|
private values(change: Partial<PassBand>): Record<string, PropertyValue> {
|
||||||
|
const locked = this.locked();
|
||||||
|
const values: Record<string, PropertyValue> = {};
|
||||||
|
if (change.frequency !== undefined && !locked.frequency)
|
||||||
|
values[FILTER_ROWS.cutoff.key] = held(change.frequency, FILTER_ROWS.cutoff, 1);
|
||||||
|
if (change.gain !== undefined && !locked.gain)
|
||||||
|
values[FILTER_ROWS.gain.key] = held(change.gain, GAIN, 0.1);
|
||||||
|
if (change.q !== undefined && !locked.q) values[FILTER_ROWS.q.key] = held(change.q, FILTER_ROWS.q, 0.001);
|
||||||
|
if (change.type !== undefined) values[FILTER_SETTINGS.type] = change.type;
|
||||||
|
if (change.slope !== undefined) values[FILTER_SETTINGS.slope] = String(change.slope);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
drag(_index: number, change: Partial<PassBand>): void {
|
||||||
|
const values = this.values(change);
|
||||||
|
if (Object.keys(values).length > 0) this.preview(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(): void {
|
||||||
|
this.commitPreview('Set filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
set(_index: number, change: Partial<PassBand>): void {
|
||||||
|
this.commit(this.values(change), 'Set filter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Filter node a panel is about, or null once it, or its asset, is gone. */
|
||||||
|
export function filterNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
|
||||||
|
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
|
||||||
|
return node?.type === SoundFxFilter.type ? node : null;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type EqArea,
|
type EqArea,
|
||||||
type EqBand,
|
type PassBand,
|
||||||
dbToY,
|
dbToY,
|
||||||
EQ_Q,
|
EQ_Q,
|
||||||
EQ_RANGES,
|
EQ_RANGES,
|
||||||
@@ -17,13 +17,19 @@ import {
|
|||||||
* Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line.
|
* Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line.
|
||||||
* A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach.
|
* A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach.
|
||||||
*/
|
*/
|
||||||
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
export function handleOf(band: PassBand, area: EqArea): { x: number; y: number } {
|
||||||
const y = dbToY(hasGain(band.type) ? band.gain : 0, area);
|
const y = dbToY(hasGain(band.type) ? band.gain : 0, area);
|
||||||
return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) };
|
return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
|
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
|
||||||
export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number {
|
export function bandAt(
|
||||||
|
bands: readonly PassBand[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
area: EqArea,
|
||||||
|
radius: number,
|
||||||
|
): number {
|
||||||
let found = -1;
|
let found = -1;
|
||||||
let best = radius * radius;
|
let best = radius * radius;
|
||||||
bands.forEach((band, i) => {
|
bands.forEach((band, i) => {
|
||||||
@@ -37,6 +43,45 @@ export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqA
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of a row's groups fit into `room`, from the left (#385): those after them are not drawn
|
||||||
|
* at all, rather than cut or scrolled.
|
||||||
|
*/
|
||||||
|
export function fittingCount(widths: readonly number[], gap: number, room: number): number {
|
||||||
|
let used = 0;
|
||||||
|
for (let i = 0; i < widths.length; i++) {
|
||||||
|
used += (i > 0 ? gap : 0) + widths[i]!;
|
||||||
|
if (used > room) return i;
|
||||||
|
}
|
||||||
|
return widths.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of the rows below the curve a height has room for (#385): each comes in only once the
|
||||||
|
* curve keeps `curveRoom` above it, the first row first.
|
||||||
|
*/
|
||||||
|
export function shownRows(height: number, curveRoom: number, rows: readonly number[], gap: number): number {
|
||||||
|
let used = curveRoom;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
used += gap + rows[i]!;
|
||||||
|
if (used > height) return i;
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which labels along one axis are drawn (#385): each one `gap` clear of the last one drawn, in the
|
||||||
|
* order given. One that would run into it is left out rather than drawn over it.
|
||||||
|
*/
|
||||||
|
export function apartLabels(spans: readonly { from: number; to: number }[], gap: number): boolean[] {
|
||||||
|
let last: { from: number; to: number } | null = null;
|
||||||
|
return spans.map((span) => {
|
||||||
|
if (last && span.from < last.to + gap && span.to > last.from - gap) return false;
|
||||||
|
last = span;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
|
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
|
||||||
export function wheeledQ(q: number, deltaY: number): number {
|
export function wheeledQ(q: number, deltaY: number): number {
|
||||||
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
|
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import type { GraphNode } from '../../core/graph';
|
import type { GraphNode } from '../../core/graph';
|
||||||
import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import type { NodeFace } from '../graph/nodeFace';
|
import type { 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';
|
import { CurveFace } from './CurveFace';
|
||||||
|
import { EqPanel } from './EqPanel';
|
||||||
|
import { FilterPanel } from './FilterPanel';
|
||||||
import { GainFace } from './GainFace';
|
import { GainFace } from './GainFace';
|
||||||
|
|
||||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */
|
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385), by type. */
|
||||||
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
||||||
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
|
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
|
||||||
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
|
if (node.type === SoundFxParametricEq.type)
|
||||||
|
return new CurveFace(new EqPanel(state, true), assetId, node.id);
|
||||||
|
if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id);
|
||||||
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
|
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
|
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
|
||||||
* Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset
|
* Parametric EQ (#370), a Compressor (#378) and a Filter (#385). A tab is a document named by its
|
||||||
* and the node (`eq:<asset>:<node>`).
|
* kind, the asset and the node (`eq:<asset>:<node>`).
|
||||||
*/
|
*/
|
||||||
import type { GraphNode } from '../../core/graph';
|
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, SoundFxFilter, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
|
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
|
||||||
import { eqTabLabel } from './EqEditor';
|
import { eqTabLabel } from './EqEditor';
|
||||||
import { eqNodeOf } from './EqPanel';
|
import { eqNodeOf } from './EqPanel';
|
||||||
|
import { filterNodeOf } from './FilterPanel';
|
||||||
|
|
||||||
export type NodeTabKind = 'eq' | 'compressor';
|
export type NodeTabKind = 'eq' | 'compressor' | 'filter';
|
||||||
|
|
||||||
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor'];
|
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor', 'filter'];
|
||||||
|
|
||||||
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
|
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
|
||||||
[SoundFxParametricEq.type]: 'eq',
|
[SoundFxParametricEq.type]: 'eq',
|
||||||
[SoundFxCompressor.type]: 'compressor',
|
[SoundFxCompressor.type]: 'compressor',
|
||||||
|
[SoundFxFilter.type]: 'filter',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The kind of tab a node opens, or null for one without. */
|
/** The kind of tab a node opens, or null for one without. */
|
||||||
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
|
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
|
||||||
|
|
||||||
|
const NODE_OF: Readonly<Record<NodeTabKind, typeof eqNodeOf>> = {
|
||||||
|
eq: eqNodeOf,
|
||||||
|
compressor: compressorNodeOf,
|
||||||
|
filter: filterNodeOf,
|
||||||
|
};
|
||||||
|
|
||||||
/** The node a tab is about, or null once it, or its asset, is gone. */
|
/** The node a tab is about, or null once it, or its asset, is gone. */
|
||||||
export function nodeOfTab(
|
export function nodeOfTab(
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -29,9 +37,11 @@ export function nodeOfTab(
|
|||||||
assetId: string,
|
assetId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): GraphNode | null {
|
): GraphNode | null {
|
||||||
return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId);
|
return NODE_OF[kind](project, assetId, nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
|
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
|
||||||
return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId);
|
return kind === 'compressor'
|
||||||
|
? compressorTabLabel(project, assetId, nodeId)
|
||||||
|
: eqTabLabel(project, assetId, nodeId);
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,10 @@ describe('a preset (#329)', () => {
|
|||||||
const chain = fxChainOf(build.layers[0]!);
|
const chain = fxChainOf(build.layers[0]!);
|
||||||
expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([
|
expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([
|
||||||
'envelope',
|
'envelope',
|
||||||
|
// The Filter is tapped for its spectrum (#385).
|
||||||
|
'tap',
|
||||||
'pass',
|
'pass',
|
||||||
|
'tap',
|
||||||
...(flanged ? ['flanger'] : []),
|
...(flanged ? ['flanger'] : []),
|
||||||
'crush',
|
'crush',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import { graphEditor, makeProject } from '../helpers';
|
|||||||
|
|
||||||
const FILE = `${'c3'.repeat(32)}.wav`;
|
const FILE = `${'c3'.repeat(32)}.wav`;
|
||||||
|
|
||||||
|
/** The stages of a chain that do something to the sound: a Filter is tapped as well (#385). */
|
||||||
|
const heard = <S extends { kind: string }>(chain: { stages: readonly S[] }): S[] =>
|
||||||
|
chain.stages.filter((s) => s.kind !== 'tap');
|
||||||
|
|
||||||
/** An instrument with nothing in it, a Notes node and a Sample playing `FILE` on every note. */
|
/** An instrument with nothing in it, a Notes node and a Sample playing `FILE` on every note. */
|
||||||
function makeInstrument() {
|
function makeInstrument() {
|
||||||
const f = makeProject();
|
const f = makeProject();
|
||||||
@@ -107,7 +111,7 @@ describe('a Filter', () => {
|
|||||||
it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => {
|
it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => {
|
||||||
const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 });
|
const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 });
|
||||||
const plan = compileSoundFx(fx.graph).compiled!;
|
const plan = compileSoundFx(fx.graph).compiled!;
|
||||||
const [stage] = fxChainOf(plan).stages;
|
const [stage] = heard(fxChainOf(plan));
|
||||||
expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 });
|
expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 });
|
||||||
// 30 as a factor, in dB.
|
// 30 as a factor, in dB.
|
||||||
expect((stage as { q: number }).q).toBeCloseTo(29.54, 2);
|
expect((stage as { q: number }).q).toBeCloseTo(29.54, 2);
|
||||||
@@ -118,7 +122,7 @@ describe('a Filter', () => {
|
|||||||
const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!;
|
const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!;
|
||||||
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
|
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
|
||||||
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
|
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
|
||||||
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
|
expect(heard(fxChainOf(compileSoundFx(peak.fx.graph).compiled!))[0]).toMatchObject({
|
||||||
stages: 1,
|
stages: 1,
|
||||||
gain: 6,
|
gain: 6,
|
||||||
});
|
});
|
||||||
@@ -127,7 +131,7 @@ describe('a Filter', () => {
|
|||||||
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']);
|
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']);
|
||||||
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
|
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
|
||||||
// A gain typed in before the type changed does nothing.
|
// A gain typed in before the type changed does nothing.
|
||||||
expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({
|
expect(heard(fxChainOf(compileSoundFx(notch.fx.graph).compiled!))[0]).toMatchObject({
|
||||||
stages: 4,
|
stages: 4,
|
||||||
gain: 0,
|
gain: 0,
|
||||||
});
|
});
|
||||||
@@ -165,7 +169,7 @@ describe('an Envelope in an instrument', () => {
|
|||||||
const build = f.compile();
|
const build = f.compile();
|
||||||
// An Envelope moving numbers only shapes no sound, and nothing is missing for it.
|
// An Envelope moving numbers only shapes no sound, and nothing is missing for it.
|
||||||
expect(build.problems).toEqual({});
|
expect(build.problems).toEqual({});
|
||||||
const [stage] = fxChainOf(build.layers[0]!).stages;
|
const [stage] = heard(fxChainOf(build.layers[0]!));
|
||||||
expect(stage).toMatchObject({
|
expect(stage).toMatchObject({
|
||||||
kind: 'pass',
|
kind: 'pass',
|
||||||
filter: 'lowpass',
|
filter: 'lowpass',
|
||||||
@@ -188,7 +192,7 @@ describe('an Envelope in an instrument', () => {
|
|||||||
[filter, 'out', f.output, 'in'],
|
[filter, 'out', f.output, 'in'],
|
||||||
]);
|
]);
|
||||||
const chain = fxChainOf(f.compile().layers[0]!, { [VELOCITY_PARAM]: 0.25 });
|
const chain = fxChainOf(f.compile().layers[0]!, { [VELOCITY_PARAM]: 0.25 });
|
||||||
expect(chain.stages[0]).toMatchObject({
|
expect(heard(chain)[0]).toMatchObject({
|
||||||
cutoff: 20,
|
cutoff: 20,
|
||||||
cutoffMod: { from: 20, to: 20000, envelope: { sustain: 0.25 } },
|
cutoffMod: { from: 20, to: 20000, envelope: { sustain: 0.25 } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* The Filter's editor and the resizable EQ and Filter nodes (#385): a Filter as one band of the
|
||||||
|
* curve, the size a node is dragged to, and what a panel leaves out when it has too little room.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { ResizeNodeCommand } from '../../src/core/commands/graphCommands';
|
||||||
|
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
|
import { filterBandOf, SoundFxParametricEq } from '../../src/core/soundfx/nodes';
|
||||||
|
import { eqBandResponse } from '../../src/core/soundfx/parametricEq';
|
||||||
|
import { apartLabels, fittingCount, shownRows } from '../../src/editor/soundfx/eqView';
|
||||||
|
import { graphEditor, makeProject, roundTrip } from '../helpers';
|
||||||
|
|
||||||
|
function eqNode() {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const create = new CreateAssetCommand('soundfx', 'Music EQ');
|
||||||
|
fixture.stack.execute(create);
|
||||||
|
const asset = create.asset as SoundFxAsset;
|
||||||
|
const node = graphEditor(fixture, asset as unknown as BehaviorAsset).addNode(SoundFxParametricEq.type);
|
||||||
|
return { ...fixture, asset, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a Filter as one band of the curve', () => {
|
||||||
|
it('reads what is typed in, with the defaults of its rows', () => {
|
||||||
|
expect(filterBandOf({})).toEqual({ type: 'lowpass', frequency: 1000, gain: 0, q: 0.707, slope: 12 });
|
||||||
|
expect(filterBandOf({ filter: 'peaking', cutoff: 250, gain: -30, q: 4, slope: '48' })).toEqual({
|
||||||
|
type: 'peaking',
|
||||||
|
frequency: 250,
|
||||||
|
gain: -30,
|
||||||
|
q: 4,
|
||||||
|
slope: 48,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a gain only where its shape has one, and holds a number to its row', () => {
|
||||||
|
expect(filterBandOf({ filter: 'highpass', gain: 12 }).gain).toBe(0);
|
||||||
|
expect(filterBandOf({ cutoff: 99999, q: 0 })).toMatchObject({ frequency: 20000, q: 0.1 });
|
||||||
|
expect(filterBandOf({ filter: 'lowshelf', gain: 80 }).gain).toBe(40);
|
||||||
|
expect(filterBandOf({ filter: 'no-such' }).type).toBe('lowpass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws an all-pass flat: it only turns the phase', () => {
|
||||||
|
const response = eqBandResponse(filterBandOf({ filter: 'allpass', q: 8 }));
|
||||||
|
for (const hz of [20, 200, 1000, 5000, 18000]) expect(Math.abs(response(hz))).toBeLessThan(1e-9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the size a node is dragged to', () => {
|
||||||
|
it('is one undo step, and the node goes back to the size its face takes by itself', () => {
|
||||||
|
const { stack, asset, node } = eqNode();
|
||||||
|
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 400.4, h: 199.6 }));
|
||||||
|
expect(node.size).toEqual({ w: 400, h: 200 });
|
||||||
|
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 500, h: 300 }));
|
||||||
|
stack.undo();
|
||||||
|
expect(node.size).toEqual({ w: 400, h: 200 });
|
||||||
|
stack.undo();
|
||||||
|
expect('size' in node).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives the save, and a size in the file that is none is left out', () => {
|
||||||
|
const { project, stack, asset, node } = eqNode();
|
||||||
|
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 320, h: 180 }));
|
||||||
|
const loaded = findAsset(roundTrip(project), asset.id) as SoundFxAsset;
|
||||||
|
expect(loaded.graph.nodes.find((n) => n.id === node.id)!.size).toEqual({ w: 320, h: 180 });
|
||||||
|
|
||||||
|
const raw = serializeProject(project) as {
|
||||||
|
assets: { id: string; graph?: { nodes: Record<string, unknown>[] } }[];
|
||||||
|
};
|
||||||
|
raw.assets.find((a) => a.id === asset.id)!.graph!.nodes[0]!.size = { w: -5, h: 'x' };
|
||||||
|
const broken = findAsset(deserializeProject(raw), asset.id) as SoundFxAsset;
|
||||||
|
expect('size' in broken.graph.nodes[0]!).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what a panel leaves out when it has too little room', () => {
|
||||||
|
it('draws a row from the left as far as its groups fit, and leaves the rest out whole', () => {
|
||||||
|
expect(fittingCount([40, 100, 60], 8, 216)).toBe(3);
|
||||||
|
expect(fittingCount([40, 100, 60], 8, 215)).toBe(2);
|
||||||
|
expect(fittingCount([40, 100, 60], 8, 148)).toBe(2);
|
||||||
|
expect(fittingCount([40, 100, 60], 8, 147)).toBe(1);
|
||||||
|
expect(fittingCount([40, 100, 60], 8, 39)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the rows under the curve in only once the curve keeps its room above them', () => {
|
||||||
|
// Curve room 120, a row of 28 and a help line of 16, 8 between.
|
||||||
|
expect(shownRows(155, 120, [28, 16], 8)).toBe(0);
|
||||||
|
expect(shownRows(156, 120, [28, 16], 8)).toBe(1);
|
||||||
|
expect(shownRows(179, 120, [28, 16], 8)).toBe(1);
|
||||||
|
expect(shownRows(180, 120, [28, 16], 8)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out an axis label that would run into the one before it', () => {
|
||||||
|
const spans = [
|
||||||
|
{ from: 0, to: 20 },
|
||||||
|
{ from: 22, to: 40 },
|
||||||
|
{ from: 30, to: 50 },
|
||||||
|
{ from: 60, to: 80 },
|
||||||
|
];
|
||||||
|
expect(apartLabels(spans, 4)).toEqual([true, false, true, true]);
|
||||||
|
expect(apartLabels(spans, 0)).toEqual([true, true, false, true]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -735,6 +735,139 @@ export async function sound(t) {
|
|||||||
JSON.stringify(eqNodeHeard.spectrum),
|
JSON.stringify(eqNodeHeard.spectrum),
|
||||||
);
|
);
|
||||||
await shot('34-eq-node');
|
await shot('34-eq-node');
|
||||||
|
// #385: the EQ node is resized by its lower right corner, one undo step: small, the row under its
|
||||||
|
// curve goes, and a little larger it comes back with what fits of it, from the left.
|
||||||
|
const eqCorner = (await eqPanel()).corner;
|
||||||
|
await drag(eqCorner.x, eqCorner.y, eqCorner.x - 600, eqCorner.y - 400);
|
||||||
|
const eqLeast = {
|
||||||
|
panel: await eqPanel(),
|
||||||
|
size: await json(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-eq').size`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const eqFull = await eqPanel();
|
||||||
|
const zoom = eqFull.rect.w / eqFull.size.w;
|
||||||
|
await drag(eqFull.corner.x, eqFull.corner.y, eqFull.corner.x - 300 * zoom, eqFull.corner.y - 160 * zoom);
|
||||||
|
const eqMid = await eqPanel();
|
||||||
|
const eqRow = ['Band', 'Type', 'Hz', 'dB', 'Q', 'Slope', 'End'];
|
||||||
|
const leftPart = (row) => row.every((name, i) => name === eqRow[i]);
|
||||||
|
check(
|
||||||
|
'an EQ node is resized by its corner as one undo step: small, its row goes; larger, the groups that fit come back from the left',
|
||||||
|
eqLeast.size?.w === 240 &&
|
||||||
|
eqLeast.size.h === 120 &&
|
||||||
|
eqLeast.panel.size.w === 240 &&
|
||||||
|
eqLeast.panel.rows.row.length === 0 &&
|
||||||
|
eqFull.size.w === 720 &&
|
||||||
|
eqFull.rows.row.join() === eqRow.join() &&
|
||||||
|
eqFull.rows.help === false &&
|
||||||
|
eqMid.size.w < 720 &&
|
||||||
|
eqMid.rows.row.length >= 1 &&
|
||||||
|
eqMid.rows.row.length < eqRow.length &&
|
||||||
|
leftPart(eqMid.rows.row),
|
||||||
|
JSON.stringify({
|
||||||
|
eqLeast,
|
||||||
|
eqFull: { size: eqFull.size, rows: eqFull.rows },
|
||||||
|
eqMid: { size: eqMid.size, rows: eqMid.rows },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
// #385: a Filter node is edited in its node as one band of the curve: its handle is dragged for the
|
||||||
|
// frequency and the gain as one undo step. A double-click on its title opens the same in a tab,
|
||||||
|
// Delete there leaves the node, and a small window draws what fits of the row and no help.
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes.push(${JSON.stringify(node('fx-filter', 'SoundFxFilter', -240, 600, { filter: 'peaking', cutoff: 600, gain: -8, q: 2 }))});
|
||||||
|
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e11');
|
||||||
|
graph.edges.push(
|
||||||
|
${JSON.stringify(edge('fx-e12', 'fx-eq', 'out', 'fx-filter', 'in'))},
|
||||||
|
${JSON.stringify(edge('fx-e13', 'fx-filter', 'out', 'fx-out', 'in'))},
|
||||||
|
);`);
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const filterPanel = () => json("__gm.eq.panel('fx-filter')");
|
||||||
|
const filterFace = await filterPanel();
|
||||||
|
const filterRect = filterFace.rect;
|
||||||
|
const filterTo = await json("__gm.eq.panelPointAt('fx-filter', 2000, 6)");
|
||||||
|
await drag(filterFace.handles[0].x, filterFace.handles[0].y, filterTo.x, filterTo.y);
|
||||||
|
const filterDragged = await filterPanel();
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const filterUndone = await filterPanel();
|
||||||
|
check(
|
||||||
|
'a Filter node shows its curve with one band between its ports, dragged for frequency and gain as one undo step',
|
||||||
|
filterFace.shown &&
|
||||||
|
filterFace.size.w === 560 &&
|
||||||
|
filterFace.size.h === 280 &&
|
||||||
|
Object.values(filterFace.inputs).every((p) => p.x < filterRect.x) &&
|
||||||
|
Object.values(filterFace.outputs).every((p) => p.x > filterRect.x + filterRect.w) &&
|
||||||
|
filterFace.rows.row.join() === 'Type,Hz,dB,Q,Slope' &&
|
||||||
|
filterFace.fields.type === 'Peaking' &&
|
||||||
|
bandNear(filterFace.bands[0], 600, -8) &&
|
||||||
|
bandNear(filterDragged.bands[0], 2000, 6) &&
|
||||||
|
bandNear(filterUndone.bands[0], 600, -8) &&
|
||||||
|
filterDragged.bands[0].q === 2,
|
||||||
|
JSON.stringify({ filterFace, filterDragged: filterDragged.bands, filterUndone: filterUndone.bands }),
|
||||||
|
);
|
||||||
|
const filterTitle = await json("__gm.soundfx.nodeAt('fx-filter')");
|
||||||
|
await t.doubleClick(filterTitle.x, filterTitle.y);
|
||||||
|
const filterTab = {
|
||||||
|
shown: await json('__gm.filter.shown()'),
|
||||||
|
rows: await json('__gm.filter.rows()'),
|
||||||
|
docs: await json('__gm.documents()'),
|
||||||
|
};
|
||||||
|
await press('Delete');
|
||||||
|
const filterKept = await json(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.some((n) => n.id === 'fx-filter')`,
|
||||||
|
);
|
||||||
|
await evaluate(`__gm.floatDocument(${JSON.stringify(`filter:${fxId}:fx-filter`)})`);
|
||||||
|
const filterWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
|
||||||
|
await drag(filterWindow.grip.x, filterWindow.grip.y, filterWindow.box.x + 470, filterWindow.box.y + 250);
|
||||||
|
const filterSmall = { rows: await json('__gm.filter.rows()'), labels: await json('__gm.filter.labels()') };
|
||||||
|
await shot('34-filter-window');
|
||||||
|
const filterRow = ['Type', 'Hz', 'dB', 'Q', 'Slope'];
|
||||||
|
check(
|
||||||
|
'a double-click opens the Filter in a tab with its help, Delete there leaves the node, and a small window draws what fits of the row and no help',
|
||||||
|
filterTab.shown?.nodeId === 'fx-filter' &&
|
||||||
|
filterTab.docs.active === `filter:${fxId}:fx-filter` &&
|
||||||
|
filterTab.rows.row.join() === filterRow.join() &&
|
||||||
|
filterTab.rows.help === true &&
|
||||||
|
filterKept &&
|
||||||
|
filterSmall.rows.help === false &&
|
||||||
|
filterSmall.rows.row.length >= 1 &&
|
||||||
|
filterSmall.rows.row.length < filterRow.length &&
|
||||||
|
filterSmall.rows.row.every((name, i) => name === filterRow[i]),
|
||||||
|
JSON.stringify({ filterTab, filterKept, filterSmall }),
|
||||||
|
);
|
||||||
|
// #385: played, the Filter node and its window both draw what passes it, as the EQ does.
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
const filterSpectra = async () => ({
|
||||||
|
node: (await filterPanel()).spectrum,
|
||||||
|
tab: await json('__gm.filter.spectrum()'),
|
||||||
|
});
|
||||||
|
const filterFrames = await filterSpectra();
|
||||||
|
await evaluate('__gm.soundfx.play()');
|
||||||
|
const filterHeard = await waitFor(
|
||||||
|
filterSpectra,
|
||||||
|
(s) => s.node.frames >= filterFrames.node.frames + 3 && s.tab.frames >= filterFrames.tab.frames + 3,
|
||||||
|
);
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
check(
|
||||||
|
'played, the Filter node and its window draw the blip’s 880 Hz behind the curve',
|
||||||
|
filterHeard.node.frames >= filterFrames.node.frames + 3 &&
|
||||||
|
filterHeard.tab.frames >= filterFrames.tab.frames + 3 &&
|
||||||
|
nearBlip(filterHeard.node.peakIn) &&
|
||||||
|
nearBlip(filterHeard.tab.peakIn) &&
|
||||||
|
filterHeard.tab.taps.listened.split(' ').includes(`${fxId}:fx-filter`),
|
||||||
|
JSON.stringify({ filterFrames, filterHeard }),
|
||||||
|
);
|
||||||
|
const filterClose = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
|
||||||
|
await click(filterClose.close.x, filterClose.close.y);
|
||||||
|
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-filter');
|
||||||
|
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13'].includes(e.id));
|
||||||
|
graph.edges.push(${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))});`);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
const eqBefore = await stats();
|
const eqBefore = await stats();
|
||||||
await click(fxPlayAt.x, fxPlayAt.y);
|
await click(fxPlayAt.x, fxPlayAt.y);
|
||||||
|
|||||||
Reference in new issue
Block a user