Shape a sound with a Parametric EQ and its own curve editor (#367)
A sound FX or an instrument had a 5-Band-EQ with fixed bands and a Filter with one band. The Parametric EQ holds up to ten bands of any type, and a double-click on the node opens an editor that draws the curve they make together. - The bands are a list on the node (`bands`): type, frequency, gain, Q and slope. A hand-edited project is held to the editor's ranges and to ten bands. - Each band compiles to the existing `pass` stage, so the audio core is unchanged. A peak or a shelf at 0 dB is left out. - The curve is worked out in `parametricEq.ts` with the biquad formulas of the Web Audio spec, so the editor draws what is heard. - The editor is a window that floats over the editor without a backdrop, so the tab's Play stays usable. A double-click adds a peak, a drag moves a band, the wheel turns its Q, a right-click removes it. Fields below the curve set the picked band. It opens in the middle of the screen and moves by its title bar. - A drag or a scrub writes the node as a preview and makes one undo step on release. The panels hear of the preview once a frame, and the window lays itself out only when one of its texts changes. - The area under the curve is filled as one shape for each side of 0 dB. As a single shape it crossed itself and came apart. - The curve works out each band's coefficients once and adds the sum up from the bands. A drag took 295 ms of script time in a profile before and 64 ms after. Decided on my own: - Bands are added by a double-click, so a single click can let go of the picked band without adding one. - The cuts are labelled "Low cut" and "High cut" as in an EQ, not "High-pass" and "Low-pass" as on the Filter. - The bands have no input, so no parameter or Envelope moves them. Tests: - Unit parametric-eq (new) and soundfx: 69 green. Counter-probe with no stages emitted: the compile test red. Counter-probe with one fill shape: the fill test red. - e2e 34 (melektaus-atx): ALL PASSED, 96 checks (3 new). - Counter-probe e2e: no stages emitted, the playing check red. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
204c4a41e8
commit
1f13794b0c
9 files changed
+1105
-4
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
toneCutoff,
|
||||
} from './saturation';
|
||||
import { PHASER_STAGES } from './phaser';
|
||||
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
||||
|
||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||
|
||||
@@ -592,6 +593,33 @@ export const SoundFxEq: SoundFxNodeDef = effect(
|
||||
'blend',
|
||||
);
|
||||
|
||||
export const SoundFxParametricEq: SoundFxNodeDef = effect(
|
||||
{
|
||||
type: 'SoundFxParametricEq',
|
||||
label: 'Parametric EQ',
|
||||
category: 'sound',
|
||||
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q. 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.${BLENDS}`,
|
||||
inputs: [audioIn],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
emit: (sounds, _amounts, settings) =>
|
||||
withStages(
|
||||
arriving(sounds),
|
||||
eqBandsOf(settings)
|
||||
.filter(isEqBandHeard)
|
||||
.map((band) => ({
|
||||
kind: 'pass',
|
||||
filter: band.type,
|
||||
stages: eqStagesOf(band),
|
||||
cutoff: { value: band.frequency },
|
||||
q: { value: band.q },
|
||||
gain: { value: hasGain(band.type) ? band.gain : 0 },
|
||||
})),
|
||||
),
|
||||
},
|
||||
'blend',
|
||||
);
|
||||
|
||||
/** The Q of a filter with no bump at its cutoff, as a factor. */
|
||||
export const FLAT_Q = Math.SQRT1_2;
|
||||
|
||||
@@ -1209,6 +1237,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
||||
SoundFxGain,
|
||||
SoundFxPitch,
|
||||
SoundFxEq,
|
||||
SoundFxParametricEq,
|
||||
SoundFxFilter,
|
||||
SoundFxMix,
|
||||
SoundFxDelay,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* The bands of a Parametric EQ (#367): up to `MAX_EQ_BANDS` filters in a row, kept as a list on the
|
||||
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
||||
* editor draws what is heard.
|
||||
*/
|
||||
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
||||
import type { FxPassFilter } from './nodes';
|
||||
|
||||
export const EQ_BANDS_PARAM = 'bands';
|
||||
export const MAX_EQ_BANDS = 10;
|
||||
|
||||
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
|
||||
export type EqSlope = 12 | 24 | 48;
|
||||
|
||||
export interface EqBand {
|
||||
type: EqBandType;
|
||||
frequency: number;
|
||||
gain: number;
|
||||
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
|
||||
q: number;
|
||||
slope: EqSlope;
|
||||
}
|
||||
|
||||
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
|
||||
{ value: 'peaking', label: 'Peaking' },
|
||||
{ value: 'lowshelf', label: 'Low shelf' },
|
||||
{ value: 'highshelf', label: 'High shelf' },
|
||||
{ value: 'highpass', label: 'Low cut' },
|
||||
{ value: 'lowpass', label: 'High cut' },
|
||||
{ value: 'bandpass', label: 'Band-pass' },
|
||||
{ value: 'notch', label: 'Notch' },
|
||||
];
|
||||
export const EQ_SLOPES: readonly EqSlope[] = [12, 24, 48];
|
||||
|
||||
export const EQ_FREQUENCY = { min: 20, max: 20000 };
|
||||
export const EQ_GAIN = { min: -24, max: 24 };
|
||||
export const EQ_Q = { min: 0.1, max: 30 };
|
||||
|
||||
/** The shapes with a gain, and those a slope makes steeper. */
|
||||
const GAINED: ReadonlySet<EqBandType> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
||||
const SLOPED: ReadonlySet<EqBandType> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
||||
|
||||
export const hasGain = (type: EqBandType): boolean => GAINED.has(type);
|
||||
export const hasSlope = (type: EqBandType): boolean => SLOPED.has(type);
|
||||
/** Web Audio's shelves have no Q: theirs is fixed. */
|
||||
export const hasQ = (type: EqBandType): boolean => type !== 'lowshelf' && type !== 'highshelf';
|
||||
|
||||
const FLAT_Q = Math.SQRT1_2;
|
||||
|
||||
const clampTo = (value: unknown, range: { min: number; max: number }, fallback: number): number => {
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(range.min, Math.min(range.max, n));
|
||||
};
|
||||
|
||||
/** A band as a hand-edited project may hold it, held to what the editor offers. */
|
||||
function bandOf(value: PropertyValue): EqBand | null {
|
||||
if (typeof value !== 'object' || isList(value)) return null;
|
||||
const item: StructValue = value;
|
||||
const type = EQ_BAND_TYPES.find((t) => t.value === item.type)?.value ?? 'peaking';
|
||||
const slope = EQ_SLOPES.find((s) => s === Number(item.slope)) ?? 12;
|
||||
return {
|
||||
type,
|
||||
frequency: clampTo(item.frequency, EQ_FREQUENCY, 1000),
|
||||
gain: clampTo(item.gain, EQ_GAIN, 0),
|
||||
q: clampTo(item.q, EQ_Q, FLAT_Q),
|
||||
slope,
|
||||
};
|
||||
}
|
||||
|
||||
export function eqBandsOf(params: PropertyBag): EqBand[] {
|
||||
const list = params[EQ_BANDS_PARAM];
|
||||
if (!isList(list)) return [];
|
||||
return list
|
||||
.map(bandOf)
|
||||
.filter((band): band is EqBand => band !== null)
|
||||
.slice(0, MAX_EQ_BANDS);
|
||||
}
|
||||
|
||||
export function eqBandsValue(bands: readonly EqBand[]): PropertyValue {
|
||||
return bands.map((band) => ({ ...band }));
|
||||
}
|
||||
|
||||
/** A new band where the editor was clicked: a peak there. */
|
||||
export function newEqBand(frequency: number, gain: number): EqBand {
|
||||
const at = clampTo(frequency, EQ_FREQUENCY, 1000);
|
||||
return { type: 'peaking', frequency: at, gain: clampTo(gain, EQ_GAIN, 0), q: 1, slope: 12 };
|
||||
}
|
||||
|
||||
/** A band with some of its values changed, held to what it may be. */
|
||||
export function withBandSet(bands: readonly EqBand[], index: number, change: Partial<EqBand>): EqBand[] {
|
||||
return bands.map((band, i) => (i === index ? (bandOf({ ...band, ...change }) ?? band) : band));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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);
|
||||
|
||||
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
|
||||
export const EQ_SAMPLE_RATE = 48000;
|
||||
|
||||
interface Biquad {
|
||||
b0: number;
|
||||
b1: number;
|
||||
b2: number;
|
||||
a0: number;
|
||||
a1: number;
|
||||
a2: number;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
|
||||
const cos = Math.cos(w0);
|
||||
const sin = Math.sin(w0);
|
||||
const a = 10 ** (gain / 40);
|
||||
// For a low- and high-pass Web Audio takes Q in dB; as a factor that is the same alpha.
|
||||
const alpha = sin / (2 * q);
|
||||
const alphaS = (sin / 2) * Math.SQRT2;
|
||||
const root = 2 * Math.sqrt(a) * alphaS;
|
||||
switch (type) {
|
||||
case 'lowpass':
|
||||
return {
|
||||
b0: (1 - cos) / 2,
|
||||
b1: 1 - cos,
|
||||
b2: (1 - cos) / 2,
|
||||
a0: 1 + alpha,
|
||||
a1: -2 * cos,
|
||||
a2: 1 - alpha,
|
||||
};
|
||||
case 'highpass':
|
||||
return {
|
||||
b0: (1 + cos) / 2,
|
||||
b1: -(1 + cos),
|
||||
b2: (1 + cos) / 2,
|
||||
a0: 1 + alpha,
|
||||
a1: -2 * cos,
|
||||
a2: 1 - alpha,
|
||||
};
|
||||
case 'bandpass':
|
||||
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||||
case 'notch':
|
||||
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
|
||||
case 'peaking':
|
||||
return {
|
||||
b0: 1 + alpha * a,
|
||||
b1: -2 * cos,
|
||||
b2: 1 - alpha * a,
|
||||
a0: 1 + alpha / a,
|
||||
a1: -2 * cos,
|
||||
a2: 1 - alpha / a,
|
||||
};
|
||||
case 'lowshelf':
|
||||
return {
|
||||
b0: a * (a + 1 - (a - 1) * cos + root),
|
||||
b1: 2 * a * (a - 1 - (a + 1) * cos),
|
||||
b2: a * (a + 1 - (a - 1) * cos - root),
|
||||
a0: a + 1 + (a - 1) * cos + root,
|
||||
a1: -2 * (a - 1 + (a + 1) * cos),
|
||||
a2: a + 1 + (a - 1) * cos - root,
|
||||
};
|
||||
case 'highshelf':
|
||||
return {
|
||||
b0: a * (a + 1 + (a - 1) * cos + root),
|
||||
b1: -2 * a * (a - 1 + (a + 1) * cos),
|
||||
b2: a * (a + 1 + (a - 1) * cos - root),
|
||||
a0: a + 1 - (a - 1) * cos + root,
|
||||
a1: 2 * (a - 1 - (a + 1) * cos),
|
||||
a2: a + 1 - (a - 1) * cos - root,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The lowest the curve goes, so a cut's deep end stays a number. */
|
||||
const FLOOR_DB = -300;
|
||||
|
||||
function biquadDb(filter: Biquad, frequency: number, rate: number): number {
|
||||
const w = (2 * Math.PI * frequency) / rate;
|
||||
const [c1, s1, c2, s2] = [Math.cos(w), Math.sin(w), Math.cos(2 * w), Math.sin(2 * w)];
|
||||
const numRe = filter.b0 + filter.b1 * c1 + filter.b2 * c2;
|
||||
const numIm = filter.b1 * s1 + filter.b2 * s2;
|
||||
const denRe = filter.a0 + filter.a1 * c1 + filter.a2 * c2;
|
||||
const denIm = filter.a1 * s1 + filter.a2 * s2;
|
||||
const power = (numRe * numRe + numIm * numIm) / (denRe * denRe + denIm * denIm);
|
||||
return Math.max(FLOOR_DB, 10 * Math.log10(power));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, so a curve of many points asks this rather than `eqBandDb`.
|
||||
*/
|
||||
export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
|
||||
const stages = eqStagesOf(band);
|
||||
const gain = hasGain(band.type) ? band.gain : 0;
|
||||
const last = biquad(band.type, band.frequency, band.q, gain, rate);
|
||||
const flat = stages > 1 ? biquad(band.type, band.frequency, FLAT_Q, 0, rate) : null;
|
||||
return (frequency) => {
|
||||
let db = biquadDb(last, frequency, rate);
|
||||
if (flat) db += (stages - 1) * biquadDb(flat, frequency, rate);
|
||||
return Math.max(FLOOR_DB, db);
|
||||
};
|
||||
}
|
||||
|
||||
/** What one band does at a frequency, in dB. */
|
||||
export function eqBandDb(band: EqBand, frequency: number, rate = EQ_SAMPLE_RATE): number {
|
||||
return eqBandResponse(band, rate)(frequency);
|
||||
}
|
||||
|
||||
/** What all bands do together at a frequency, in dB. */
|
||||
export function eqCurveDb(bands: readonly EqBand[], frequency: number, rate = EQ_SAMPLE_RATE): number {
|
||||
let db = 0;
|
||||
for (const band of bands) if (isEqBandHeard(band)) db += eqBandDb(band, frequency, rate);
|
||||
return Math.max(FLOOR_DB, db);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
import { currentEqEditor } from '../soundfx/EqEditor';
|
||||
import { rectOf } from '../textProbe';
|
||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
|
||||
@@ -114,6 +115,35 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
/** The header's parameter values, and setting one as its field would. */
|
||||
params: () => editor.soundFxEditor.previewValues,
|
||||
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||||
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
|
||||
},
|
||||
/** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */
|
||||
eq: {
|
||||
shown: () => currentEqEditor()?.shownFor ?? null,
|
||||
bands: () => currentEqEditor()?.bands() ?? null,
|
||||
picked: () => currentEqEditor()?.picked ?? null,
|
||||
handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null,
|
||||
pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null,
|
||||
fields: () => {
|
||||
const eq = currentEqEditor();
|
||||
return (
|
||||
eq && {
|
||||
type: eq.typeButton.text,
|
||||
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
|
||||
gain: eq.gainField.enabled ? eq.gainField.value : null,
|
||||
q: eq.qField.enabled ? eq.qField.value : null,
|
||||
frequency: eq.frequencyField.value,
|
||||
}
|
||||
);
|
||||
},
|
||||
typeAt: () => {
|
||||
const eq = currentEqEditor();
|
||||
return eq ? centreOf(eq.typeButton) : null;
|
||||
},
|
||||
closeAt: () => {
|
||||
const eq = currentEqEditor();
|
||||
return eq ? centreOf(eq.closeButton) : null;
|
||||
},
|
||||
},
|
||||
instrument: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
|
||||
@@ -30,6 +30,8 @@ import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||
import { openEqEditor } from '../soundfx/EqEditor';
|
||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||
import { masterRoutingProblems } from '../../core/music/projectRouting';
|
||||
import { routingProblems } from '../../core/music/routing';
|
||||
@@ -669,6 +671,10 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
if (node.type === SoundFxParametricEq.type) {
|
||||
openEqEditor(this.state, this.assetId, node.id);
|
||||
return true;
|
||||
}
|
||||
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
|
||||
if (node.type === ROUTED_ASSET) {
|
||||
const asset = findAsset(this.state.project, node.id);
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* The editor of a Parametric EQ node (#367): a window that floats over the editor and leaves it
|
||||
* usable, so the tab's Play can be pressed while the bands are set. The curve is the sum of the
|
||||
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
||||
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
||||
* undo step.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import type { GraphNode } from '../../core/graph';
|
||||
import { findGraphAsset } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import {
|
||||
type EqBand,
|
||||
EQ_BAND_TYPES,
|
||||
EQ_BANDS_PARAM,
|
||||
EQ_FREQUENCY,
|
||||
EQ_GAIN,
|
||||
EQ_Q,
|
||||
EQ_SLOPES,
|
||||
eqBandResponse,
|
||||
eqBandsOf,
|
||||
eqBandsValue,
|
||||
hasGain,
|
||||
hasQ,
|
||||
hasSlope,
|
||||
isEqBandHeard,
|
||||
MAX_EQ_BANDS,
|
||||
newEqBand,
|
||||
withBandSet,
|
||||
} from '../../core/soundfx/parametricEq';
|
||||
import {
|
||||
Button,
|
||||
crispText,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
NumberField,
|
||||
Popup,
|
||||
pressCount,
|
||||
Spacer,
|
||||
Theme,
|
||||
textStyle,
|
||||
ui,
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import type { EditorState } from '../state';
|
||||
import {
|
||||
bandAt,
|
||||
dbToY,
|
||||
type EqArea,
|
||||
EQ_GRID_DB,
|
||||
EQ_GRID_FREQUENCIES,
|
||||
fillShapes,
|
||||
frequencyToX,
|
||||
handleOf,
|
||||
wheeledQ,
|
||||
xToFrequency,
|
||||
yToDb,
|
||||
} from './eqView';
|
||||
|
||||
const CURVE_WIDTH = 640;
|
||||
const CURVE_HEIGHT = 260;
|
||||
const HANDLE_RADIUS = 7;
|
||||
const GRAB_RADIUS = HANDLE_RADIUS + 4;
|
||||
/** Pixels of travel before a press on a band becomes a drag. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** Points the curve is drawn through, evenly in octaves. */
|
||||
const CURVE_STEPS = 320;
|
||||
const BAND_COLORS = [
|
||||
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
||||
];
|
||||
|
||||
/** What the curve asks of the window around it. */
|
||||
interface EqHost {
|
||||
bands(): EqBand[];
|
||||
picked: number;
|
||||
pick(index: number): void;
|
||||
preview(bands: EqBand[]): void;
|
||||
commit(bands: EqBand[]): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
class EqCurve extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly lines = new Graphics();
|
||||
private readonly handles = new Graphics();
|
||||
private readonly labels: Text[] = [];
|
||||
private readonly numbers: Text[] = [];
|
||||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||||
|
||||
constructor(private readonly host: EqHost) {
|
||||
super();
|
||||
this.minWidth = CURVE_WIDTH;
|
||||
this.minHeight = CURVE_HEIGHT;
|
||||
this.cursor = 'crosshair';
|
||||
this.addChild(this.bg, this.grid, this.lines, this.handles);
|
||||
const muted = textStyle('small').clone();
|
||||
muted.fill = Theme.colors.textMuted;
|
||||
const onHandle = textStyle('small').clone();
|
||||
onHandle.fill = Theme.colors.symbolOutline;
|
||||
for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted }));
|
||||
for (const db of EQ_GRID_DB)
|
||||
this.labels.push(crispText({ text: db > 0 ? `+${db}` : `${db}`, style: muted }));
|
||||
for (let i = 0; i < MAX_EQ_BANDS; i++)
|
||||
this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle }));
|
||||
for (const text of [...this.labels, ...this.numbers]) {
|
||||
text.eventMode = 'none';
|
||||
this.addChild(text);
|
||||
}
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
|
||||
this.onPointerRelease(() => this.release());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e));
|
||||
}
|
||||
|
||||
get area(): EqArea {
|
||||
return { w: this.w, h: this.h };
|
||||
}
|
||||
|
||||
/** Where a band's handle is on screen – the check scripts aim with it. */
|
||||
handleGlobal(index: number): { x: number; y: number } | null {
|
||||
const band = this.host.bands()[index];
|
||||
if (!band) return null;
|
||||
const at = handleOf(band, this.area);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + at.x, y: origin.y + at.y };
|
||||
}
|
||||
|
||||
/** Where a frequency and a gain are on screen. */
|
||||
pointGlobal(frequency: number, db: number): { x: number; y: number } {
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + frequencyToX(frequency, this.area), y: origin.y + dbToY(db, this.area) };
|
||||
}
|
||||
|
||||
private local(e: FederatedPointerEvent): { x: number; y: number } {
|
||||
return this.toLocal(e.global);
|
||||
}
|
||||
|
||||
private press(e: FederatedPointerEvent): void {
|
||||
const at = this.local(e);
|
||||
const bands = this.host.bands();
|
||||
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||
if (e.button === 2) {
|
||||
if (index >= 0) {
|
||||
this.host.pick(-1);
|
||||
this.host.commit(bands.filter((_, i) => i !== index));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (index >= 0) {
|
||||
this.host.pick(index);
|
||||
this.drag = { index, startX: e.global.x, startY: e.global.y, moved: false };
|
||||
return;
|
||||
}
|
||||
// A double-click on the free curve puts a band there; a single one lets go of the picked one.
|
||||
if (pressCount(e, 'eq-curve') === 2 && bands.length < MAX_EQ_BANDS) {
|
||||
const band = newEqBand(
|
||||
Math.round(xToFrequency(at.x, this.area)),
|
||||
Math.round(yToDb(at.y, this.area) * 10) / 10,
|
||||
);
|
||||
this.host.commit([...bands, band]);
|
||||
this.host.pick(bands.length);
|
||||
return;
|
||||
}
|
||||
this.host.pick(-1);
|
||||
}
|
||||
|
||||
private move(e: FederatedPointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD)
|
||||
return;
|
||||
drag.moved = true;
|
||||
const at = this.local(e);
|
||||
const bands = this.host.bands();
|
||||
const band = bands[drag.index];
|
||||
if (!band) return;
|
||||
const frequency = Math.round(xToFrequency(at.x, this.area));
|
||||
const gain = Math.round(yToDb(at.y, this.area) * 10) / 10;
|
||||
this.host.preview(
|
||||
withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }),
|
||||
);
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (!drag?.moved) return;
|
||||
this.host.commit(this.host.bands());
|
||||
}
|
||||
|
||||
/** The wheel over a band, or anywhere while one is picked, turns its Q. */
|
||||
private wheel(e: FederatedWheelEvent): void {
|
||||
const at = this.toLocal(e.global);
|
||||
const bands = this.host.bands();
|
||||
const under = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||
const index = under >= 0 ? under : this.host.picked;
|
||||
const band = bands[index];
|
||||
if (!band || !hasQ(band.type) || e.deltaY === 0) return;
|
||||
e.stopPropagation();
|
||||
this.host.pick(index);
|
||||
this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) }));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
const area = this.area;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
|
||||
|
||||
this.grid.clear();
|
||||
let label = 0;
|
||||
for (const { hz } of EQ_GRID_FREQUENCIES) {
|
||||
const x = Math.round(frequencyToX(hz, area)) + 0.5;
|
||||
this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||||
const text = this.labels[label++]!;
|
||||
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
|
||||
}
|
||||
for (const db of EQ_GRID_DB) {
|
||||
const y = Math.round(dbToY(db, area)) + 0.5;
|
||||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||
const text = this.labels[label++]!;
|
||||
text.position.set(3, y - text.height - 1);
|
||||
}
|
||||
this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 });
|
||||
const zero = Math.round(dbToY(0, area)) + 0.5;
|
||||
this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 });
|
||||
|
||||
// Each band once at every step; the sum is added up from them rather than worked out again.
|
||||
const bands = this.host.bands();
|
||||
this.lines.clear();
|
||||
const xs = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => (i / CURVE_STEPS) * this.w);
|
||||
const frequencies = xs.map((x) => xToFrequency(x, area));
|
||||
const yOf = (db: number) => Math.max(-2, Math.min(this.h + 2, dbToY(db, area)));
|
||||
const sum = new Array<number>(xs.length).fill(0);
|
||||
bands.forEach((band, i) => {
|
||||
const response = eqBandResponse(band);
|
||||
const dbs = frequencies.map(response);
|
||||
if (isEqBandHeard(band)) dbs.forEach((db, j) => (sum[j]! += db));
|
||||
this.trace(xs, dbs.map(yOf));
|
||||
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||||
});
|
||||
const ys = sum.map(yOf);
|
||||
for (const shape of fillShapes(xs, ys, zero))
|
||||
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||||
this.trace(xs, ys);
|
||||
this.lines.stroke({ color: c.accent, width: 2 });
|
||||
|
||||
this.handles.clear();
|
||||
this.numbers.forEach((text, i) => {
|
||||
const band = bands[i];
|
||||
text.visible = Boolean(band);
|
||||
if (!band) return;
|
||||
const at = handleOf(band, area);
|
||||
this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]);
|
||||
if (i === this.host.picked)
|
||||
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
|
||||
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
|
||||
});
|
||||
}
|
||||
|
||||
/** A path through the points. */
|
||||
private trace(xs: readonly number[], ys: readonly number[]): void {
|
||||
this.lines.moveTo(xs[0]!, ys[0]!);
|
||||
for (let i = 1; i < xs.length; i++) this.lines.lineTo(xs[i]!, ys[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One window for the node last double-clicked. It closes with its node, with the project, or by its
|
||||
* own button.
|
||||
*/
|
||||
export class EqWindow extends Popup implements EqHost {
|
||||
readonly curve: EqCurve;
|
||||
private assetId = '';
|
||||
private nodeId = '';
|
||||
picked = -1;
|
||||
/** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */
|
||||
private before: PropertyValue | undefined | null = null;
|
||||
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
|
||||
private readonly count = new Label('', { style: 'muted' });
|
||||
private readonly header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
private readonly bandLabel = new Label('', { style: 'small' });
|
||||
readonly typeButton: Dropdown;
|
||||
readonly slopeButton: Dropdown;
|
||||
readonly frequencyField: NumberField;
|
||||
readonly gainField: NumberField;
|
||||
readonly qField: NumberField;
|
||||
readonly removeButton: Button;
|
||||
readonly closeButton: Button;
|
||||
private readonly unsubscribe: (() => void)[] = [];
|
||||
private readonly perFrame: FramePreview;
|
||||
/** The texts the window was last laid out for; a drag changes only numbers and needs no layout. */
|
||||
private laidOut = '';
|
||||
private grab: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ backdrop: false, padding: Theme.padding * 2, gap: Theme.padding });
|
||||
this.perFrame = new FramePreview(state, 'eq-band');
|
||||
this.curve = new EqCurve(this);
|
||||
this.closeButton = new Button('Close', { variant: 'ghost', onClick: () => this.close() });
|
||||
const header = this.header;
|
||||
header.add(this.title);
|
||||
header.add(this.count);
|
||||
header.add(new Spacer(), 1);
|
||||
header.add(this.closeButton);
|
||||
header.cursor = 'move';
|
||||
header.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.target !== header) return;
|
||||
this.grab = { x: e.global.x - this.x, y: e.global.y - this.y };
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (!this.grab) return;
|
||||
const screen = ui().app.screen;
|
||||
this.position.set(
|
||||
Math.round(Math.max(0, Math.min(screen.width - this.w, e.global.x - this.grab.x))),
|
||||
Math.round(Math.max(0, Math.min(screen.height - this.h, e.global.y - this.grab.y))),
|
||||
);
|
||||
});
|
||||
this.onPointerRelease(() => (this.grab = null));
|
||||
|
||||
const numbers = (change: (value: number) => Partial<EqBand>) => ({
|
||||
onInput: (value: number) => this.previewPicked(change(value)),
|
||||
onChange: (value: number) => this.commitPicked(change(value)),
|
||||
});
|
||||
this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() });
|
||||
this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() });
|
||||
this.frequencyField = new NumberField(1000, {
|
||||
width: 64,
|
||||
min: EQ_FREQUENCY.min,
|
||||
max: EQ_FREQUENCY.max,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
...numbers((frequency) => ({ frequency })),
|
||||
});
|
||||
this.gainField = new NumberField(0, {
|
||||
width: 52,
|
||||
min: EQ_GAIN.min,
|
||||
max: EQ_GAIN.max,
|
||||
step: 0.1,
|
||||
decimals: 1,
|
||||
...numbers((gain) => ({ gain })),
|
||||
});
|
||||
this.qField = new NumberField(1, {
|
||||
width: 52,
|
||||
min: EQ_Q.min,
|
||||
max: EQ_Q.max,
|
||||
step: 0.01,
|
||||
decimals: 3,
|
||||
...numbers((q) => ({ q })),
|
||||
});
|
||||
this.removeButton = new Button('Remove', { variant: 'ghost', onClick: () => this.removePicked() });
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.add(this.bandLabel);
|
||||
for (const [name, widget] of [
|
||||
['Type', this.typeButton],
|
||||
['Hz', this.frequencyField],
|
||||
['dB', this.gainField],
|
||||
['Q', this.qField],
|
||||
['Slope', this.slopeButton],
|
||||
] as const) {
|
||||
row.add(new Label(name, { style: 'small' }));
|
||||
row.add(widget);
|
||||
}
|
||||
row.add(new Spacer(), 1);
|
||||
row.add(this.removeButton);
|
||||
|
||||
this.content.add(header);
|
||||
this.content.add(this.curve);
|
||||
this.content.add(row);
|
||||
this.content.add(
|
||||
new Label(
|
||||
`Double-click to add a band (up to ${MAX_EQ_BANDS}). Drag a band for its frequency and gain, turn the mouse wheel for its Q, right-click it to remove it.`,
|
||||
{ style: 'muted' },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens for a node, or turns an open window to it. */
|
||||
show(assetId: string, nodeId: string): void {
|
||||
const other = assetId !== this.assetId || nodeId !== this.nodeId;
|
||||
this.assetId = assetId;
|
||||
this.nodeId = nodeId;
|
||||
if (other) this.picked = this.bands().length > 0 ? 0 : -1;
|
||||
if (!this.isOpen) {
|
||||
this.unsubscribe.push(
|
||||
this.state.on('project', () => this.sync()),
|
||||
this.state.on('load', () => this.close()),
|
||||
);
|
||||
this.openCentered();
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get shownFor(): { assetId: string; nodeId: string } | null {
|
||||
return this.isOpen ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
for (const off of this.unsubscribe.splice(0)) off();
|
||||
this.perFrame.cancel();
|
||||
this.laidOut = '';
|
||||
super.close();
|
||||
}
|
||||
|
||||
private node(): GraphNode | null {
|
||||
return (
|
||||
findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find((n) => n.id === this.nodeId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
bands(): EqBand[] {
|
||||
const node = this.node();
|
||||
return node ? eqBandsOf(node.params) : [];
|
||||
}
|
||||
|
||||
pick(index: number): void {
|
||||
if (this.picked === index) return;
|
||||
this.picked = index;
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The
|
||||
* panels hear of it once a frame, however fast the mouse reports.
|
||||
*/
|
||||
preview(bands: EqBand[]): void {
|
||||
const node = this.node();
|
||||
if (!node) return;
|
||||
if (this.before === null) this.before = node.params[EQ_BANDS_PARAM];
|
||||
node.params[EQ_BANDS_PARAM] = eqBandsValue(bands);
|
||||
this.perFrame.request();
|
||||
}
|
||||
|
||||
commit(bands: EqBand[]): void {
|
||||
this.perFrame.cancel();
|
||||
const node = this.node();
|
||||
if (!node) return;
|
||||
this.restore(node);
|
||||
const value = eqBandsValue(bands);
|
||||
if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) {
|
||||
this.state.preview();
|
||||
return;
|
||||
}
|
||||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value));
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.perFrame.cancel();
|
||||
const node = this.node();
|
||||
if (node) this.restore(node);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
private restore(node: GraphNode): void {
|
||||
if (this.before === null) return;
|
||||
if (this.before === undefined) delete node.params[EQ_BANDS_PARAM];
|
||||
else node.params[EQ_BANDS_PARAM] = this.before;
|
||||
this.before = null;
|
||||
}
|
||||
|
||||
private previewPicked(change: Partial<EqBand>): void {
|
||||
if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change));
|
||||
}
|
||||
|
||||
private commitPicked(change: Partial<EqBand>): void {
|
||||
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
|
||||
}
|
||||
|
||||
private removePicked(): void {
|
||||
const index = this.picked;
|
||||
if (index < 0) return;
|
||||
this.picked = -1;
|
||||
this.commit(this.bands().filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
private openTypeMenu(): void {
|
||||
const band = this.bands()[this.picked];
|
||||
if (!band) return;
|
||||
const items = EQ_BAND_TYPES.map((type) => ({
|
||||
label: type.label,
|
||||
current: type.value === band.type,
|
||||
onSelect: () => this.commitPicked({ type: type.value }),
|
||||
}));
|
||||
new Menu(items).openBelow(this.typeButton);
|
||||
}
|
||||
|
||||
private openSlopeMenu(): void {
|
||||
const band = this.bands()[this.picked];
|
||||
if (!band) return;
|
||||
const items = EQ_SLOPES.map((slope) => ({
|
||||
label: `${slope} dB/oct`,
|
||||
current: slope === band.slope,
|
||||
onSelect: () => this.commitPicked({ slope }),
|
||||
}));
|
||||
new Menu(items).openBelow(this.slopeButton);
|
||||
}
|
||||
|
||||
/** Follows the node: gone, the window goes with it. */
|
||||
private sync(): void {
|
||||
if (!this.isOpen) return;
|
||||
const node = this.node();
|
||||
if (!node) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
const bands = eqBandsOf(node.params);
|
||||
if (this.picked >= bands.length) this.picked = -1;
|
||||
const band = bands[this.picked];
|
||||
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
|
||||
const title = `Parametric EQ – ${name}`;
|
||||
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
|
||||
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
|
||||
this.title.setText(title);
|
||||
this.count.setText(count);
|
||||
this.bandLabel.setText(bandLabel);
|
||||
this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—');
|
||||
this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—');
|
||||
this.frequencyField.setNumber(band?.frequency ?? 1000);
|
||||
this.gainField.setNumber(band?.gain ?? 0);
|
||||
this.qField.setNumber(band?.q ?? 1);
|
||||
this.typeButton.setEnabled(Boolean(band));
|
||||
this.frequencyField.setEnabled(Boolean(band));
|
||||
this.gainField.setEnabled(Boolean(band && hasGain(band.type)));
|
||||
this.qField.setEnabled(Boolean(band && hasQ(band.type)));
|
||||
this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type)));
|
||||
this.removeButton.setEnabled(Boolean(band));
|
||||
const texts = [title, count, bandLabel].join('\n');
|
||||
if (texts !== this.laidOut) {
|
||||
this.laidOut = texts;
|
||||
this.header.relayout();
|
||||
this.content.relayout();
|
||||
}
|
||||
this.curve.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
let eqWindow: EqWindow | null = null;
|
||||
|
||||
/** Opens the editor of a Parametric EQ node; one window, turned to whichever node asked last. */
|
||||
export function openEqEditor(state: EditorState, assetId: string, nodeId: string): EqWindow {
|
||||
eqWindow ??= new EqWindow(state);
|
||||
eqWindow.show(assetId, nodeId);
|
||||
return eqWindow;
|
||||
}
|
||||
|
||||
/** The open window, for the check scripts. */
|
||||
export function currentEqEditor(): EqWindow | null {
|
||||
return eqWindow?.isOpen ? eqWindow : null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* How the Parametric EQ editor (#367) lays its curve onto a picture: frequency to the right in
|
||||
* octaves, from 20 Hz to 20 kHz, and dB up, the same distance either side of 0.
|
||||
*/
|
||||
import { type EqBand, EQ_FREQUENCY, EQ_GAIN, EQ_Q, hasGain } from '../../core/soundfx/parametricEq';
|
||||
|
||||
export interface EqArea {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** The dB the picture shows above and below 0. */
|
||||
export const EQ_VIEW_DB = EQ_GAIN.max;
|
||||
|
||||
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
|
||||
|
||||
export const frequencyToX = (frequency: number, area: EqArea): number =>
|
||||
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
|
||||
|
||||
export const xToFrequency = (x: number, area: EqArea): number =>
|
||||
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
|
||||
|
||||
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / EQ_VIEW_DB) * (area.h / 2);
|
||||
|
||||
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB;
|
||||
|
||||
/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */
|
||||
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
||||
return { x: frequencyToX(band.frequency, area), y: dbToY(hasGain(band.type) ? band.gain : 0, area) };
|
||||
}
|
||||
|
||||
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
|
||||
export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number {
|
||||
let found = -1;
|
||||
let best = radius * radius;
|
||||
bands.forEach((band, i) => {
|
||||
const at = handleOf(band, area);
|
||||
const d = (at.x - x) ** 2 + (at.y - y) ** 2;
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
found = i;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
|
||||
export function wheeledQ(q: number, deltaY: number): number {
|
||||
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
|
||||
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
|
||||
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
|
||||
* line, and its fill would come apart.
|
||||
*/
|
||||
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
|
||||
const shapes: number[][] = [];
|
||||
let shape = [xs[0]!, zero];
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const after = ys[i]! - zero;
|
||||
const before = i > 0 ? ys[i - 1]! - zero : 0;
|
||||
if (before * after < 0) {
|
||||
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
|
||||
shapes.push([...shape, x, zero]);
|
||||
shape = [x, zero];
|
||||
}
|
||||
shape.push(xs[i]!, ys[i]!);
|
||||
}
|
||||
shapes.push([...shape, xs.at(-1)!, zero]);
|
||||
return shapes;
|
||||
}
|
||||
|
||||
/** The lines the picture is ruled with, and what they say. */
|
||||
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
||||
{ hz: 20, label: '20' },
|
||||
{ hz: 50, label: '50' },
|
||||
{ hz: 100, label: '100' },
|
||||
{ hz: 200, label: '200' },
|
||||
{ hz: 500, label: '500' },
|
||||
{ hz: 1000, label: '1k' },
|
||||
{ hz: 2000, label: '2k' },
|
||||
{ hz: 5000, label: '5k' },
|
||||
{ hz: 10000, label: '10k' },
|
||||
{ hz: 20000, label: '20k' },
|
||||
];
|
||||
export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18];
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, and the curve
|
||||
* the editor draws from them.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||||
import { fxChainOf } from '../../src/core/soundfx/nodes';
|
||||
import {
|
||||
type EqBand,
|
||||
eqBandDb,
|
||||
eqBandsOf,
|
||||
eqCurveDb,
|
||||
MAX_EQ_BANDS,
|
||||
withBandSet,
|
||||
} from '../../src/core/soundfx/parametricEq';
|
||||
import { fillShapes } from '../../src/editor/soundfx/eqView';
|
||||
import { graphEditor, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const band = (change: Partial<EqBand>): EqBand => ({
|
||||
type: 'peaking',
|
||||
frequency: 1000,
|
||||
gain: 0,
|
||||
q: 1,
|
||||
slope: 12,
|
||||
...change,
|
||||
});
|
||||
|
||||
function eqLine(bands: unknown) {
|
||||
const f = makeProject();
|
||||
const command = new CreateAssetCommand('soundfx', 'EQ');
|
||||
f.stack.execute(command);
|
||||
const fx = command.asset as SoundFxAsset;
|
||||
const edit = graphEditor(f, fx as unknown as BehaviorAsset);
|
||||
const input = edit.addNode('SoundFxInput');
|
||||
const eq = edit.addNode('SoundFxParametricEq');
|
||||
eq.params.bands = bands as never;
|
||||
const output = edit.addNode('SoundFxOutput');
|
||||
edit.connect(input, 'out', eq, 'in');
|
||||
edit.connect(eq, 'out', output, 'in');
|
||||
return { ...f, fx };
|
||||
}
|
||||
|
||||
describe('the bands of a Parametric EQ', () => {
|
||||
it('are held to what the editor offers, at most ten of them', () => {
|
||||
const bands = eqBandsOf({
|
||||
bands: [
|
||||
{ type: 'lowpass', frequency: 99999, gain: 3, q: 0, slope: 24 },
|
||||
{ type: 'allpass', frequency: 5, gain: -99, q: 'x', slope: 7 },
|
||||
'not a band',
|
||||
...Array.from({ length: 12 }, () => ({ type: 'peaking', frequency: 500 })),
|
||||
],
|
||||
});
|
||||
expect(bands).toHaveLength(MAX_EQ_BANDS);
|
||||
expect(bands[0]).toEqual({ type: 'lowpass', frequency: 20000, gain: 3, q: 0.1, slope: 24 });
|
||||
expect(bands[1]).toEqual({ type: 'peaking', frequency: 20, gain: -24, q: Math.SQRT1_2, slope: 12 });
|
||||
expect(eqBandsOf({})).toEqual([]);
|
||||
expect(withBandSet([band({})], 0, { gain: 40 })[0]!.gain).toBe(24);
|
||||
});
|
||||
|
||||
it('compile to one filter each, a steeper cut to more, and a flat peak to none', () => {
|
||||
const f = eqLine([
|
||||
band({ gain: 6 }),
|
||||
band({ type: 'highpass', frequency: 80, q: Math.SQRT1_2, slope: 24 }),
|
||||
band({ type: 'lowshelf', frequency: 200, gain: 0 }),
|
||||
]);
|
||||
const build = compileSoundFx(f.fx.graph);
|
||||
expect(build.effects).toBe(1);
|
||||
expect(fxChainOf(build.compiled!).stages).toEqual([
|
||||
{ kind: 'pass', filter: 'peaking', stages: 1, cutoff: 1000, q: 1, gain: 6 },
|
||||
{ kind: 'pass', filter: 'highpass', stages: 2, cutoff: 80, q: 20 * Math.log10(Math.SQRT1_2), gain: 0 },
|
||||
]);
|
||||
expect(roundTrip(f.project)).toEqual(f.project);
|
||||
});
|
||||
|
||||
it('draw the curve Web Audio plays', () => {
|
||||
const peak = band({ gain: 6 });
|
||||
expect(eqBandDb(peak, 1000)).toBeCloseTo(6, 3);
|
||||
expect(Math.abs(eqBandDb(peak, 20))).toBeLessThan(0.1);
|
||||
|
||||
// A cut with no bump is 3 dB down at its frequency and falls 12 dB an octave below it.
|
||||
const cut = band({ type: 'highpass', frequency: 200, q: Math.SQRT1_2 });
|
||||
expect(eqBandDb(cut, 200)).toBeCloseTo(-3.01, 1);
|
||||
expect(eqBandDb(cut, 50) - eqBandDb(cut, 25)).toBeCloseTo(12, 0);
|
||||
const steeper = { ...cut, slope: 48 as const };
|
||||
expect(eqBandDb(steeper, 50) - eqBandDb(steeper, 25)).toBeCloseTo(48, 0);
|
||||
|
||||
const shelf = band({ type: 'lowshelf', frequency: 200, gain: 6 });
|
||||
expect(eqBandDb(shelf, 20)).toBeCloseTo(6, 1);
|
||||
expect(eqBandDb(shelf, 200)).toBeCloseTo(3, 1);
|
||||
expect(eqBandDb(band({ type: 'notch' }), 1000)).toBeLessThan(-60);
|
||||
|
||||
// Together they add up in dB.
|
||||
const both = [peak, shelf];
|
||||
expect(eqCurveDb(both, 300)).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 6);
|
||||
});
|
||||
|
||||
it('fills a curve that crosses 0 dB with one shape for each side, meeting on the line', () => {
|
||||
// From 10 above the line at y 50 to 10 below and back: it crosses twice, at x 15 and x 45.
|
||||
const shapes = fillShapes([0, 10, 20, 30, 40, 50, 60], [40, 40, 60, 60, 60, 40, 40], 50);
|
||||
expect(shapes).toEqual([
|
||||
[0, 50, 0, 40, 10, 40, 15, 50],
|
||||
[15, 50, 20, 60, 30, 60, 40, 60, 45, 50],
|
||||
[45, 50, 50, 40, 60, 40, 60, 50],
|
||||
]);
|
||||
// On one side all along it stays one shape.
|
||||
expect(fillShapes([0, 10, 20], [40, 30, 40], 50)).toEqual([[0, 50, 0, 40, 10, 30, 20, 40, 20, 50]]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { RenamePortCommand, SetPortDefaultCommand } from '../../src/core/command
|
||||
import { callProblem, resolveDef, soundFxParamsMismatch } from '../../src/core/graph';
|
||||
import { type DeclaredPort, portsCompatible } from '../../src/core/graph/Graph';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||
@@ -63,7 +64,7 @@ function makeFx(f: ProjectFixture = makeProject()) {
|
||||
}
|
||||
|
||||
/** Input, the given effects in a row, Output. */
|
||||
function line(effects: { type: string; params?: Record<string, number | boolean | string> }[]) {
|
||||
function line(effects: { type: string; params?: PropertyBag }[]) {
|
||||
const f = makeFx();
|
||||
const input = f.edit.addNode('SoundFxInput');
|
||||
let tail = input;
|
||||
@@ -126,6 +127,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxGain',
|
||||
'SoundFxPitch',
|
||||
'SoundFxEq',
|
||||
'SoundFxParametricEq',
|
||||
'SoundFxFilter',
|
||||
'SoundFxMix',
|
||||
'SoundFxDelay',
|
||||
@@ -146,6 +148,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxGain',
|
||||
'SoundFxPitch',
|
||||
'SoundFxEq',
|
||||
'SoundFxParametricEq',
|
||||
'SoundFxFilter',
|
||||
'SoundFxMix',
|
||||
'SoundFxDelay',
|
||||
@@ -596,6 +599,7 @@ describe('Bypass and Wet (#354)', () => {
|
||||
SoundFxGain: 'wet',
|
||||
SoundFxLimiter: 'wet',
|
||||
SoundFxPan: 'wet',
|
||||
SoundFxParametricEq: 'wet',
|
||||
SoundFxPhaser: 'mix',
|
||||
SoundFxPitch: 'wet',
|
||||
SoundFxReverb: 'mix',
|
||||
@@ -608,7 +612,7 @@ describe('Bypass and Wet (#354)', () => {
|
||||
const node = f.fx.graph.nodes.find((n) => n.type === type)!;
|
||||
return resolveDef(node, f.fx.graph, graphLookup(f.project))!.inputs;
|
||||
};
|
||||
const stagesOf = (type: string, params: Record<string, number | boolean | string> = {}) =>
|
||||
const stagesOf = (type: string, params: PropertyBag = {}) =>
|
||||
compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages;
|
||||
|
||||
it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => {
|
||||
@@ -625,12 +629,12 @@ describe('Bypass and Wet (#354)', () => {
|
||||
def.type === 'SoundFxMix',
|
||||
]);
|
||||
}
|
||||
expect(Object.keys(EFFECTS)).toHaveLength(15);
|
||||
expect(Object.keys(EFFECTS)).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => {
|
||||
for (const type of Object.keys(EFFECTS)) {
|
||||
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50 };
|
||||
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50, bands: [{ gain: 6 }] };
|
||||
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
|
||||
expect([
|
||||
type,
|
||||
|
||||
@@ -520,6 +520,71 @@ export async function sound(t) {
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
|
||||
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
|
||||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||||
// #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the
|
||||
// curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the
|
||||
// voice passes one filter for it.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))});
|
||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||||
graph.edges.push(
|
||||
${JSON.stringify(edge('fx-e10', 'fx-gain', 'out', 'fx-eq', 'in'))},
|
||||
${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))},
|
||||
);`);
|
||||
const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')");
|
||||
await t.doubleClick(eqNode.x, eqNode.y);
|
||||
const eqShown = await json('__gm.eq.shown()');
|
||||
const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)');
|
||||
await t.doubleClick(eqPeakAt.x, eqPeakAt.y);
|
||||
const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }');
|
||||
const bandNear = (band, hz, db) =>
|
||||
band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5;
|
||||
check(
|
||||
'a double-click opens the Parametric EQ’s editor, and one on its curve adds a peak there',
|
||||
eqShown?.nodeId === 'fx-eq' &&
|
||||
eqAdded.bands.length === 1 &&
|
||||
eqAdded.bands[0].type === 'peaking' &&
|
||||
bandNear(eqAdded.bands[0], 1000, 6) &&
|
||||
eqAdded.fields.type === 'Peaking' &&
|
||||
eqAdded.fields.slope === null,
|
||||
JSON.stringify({ eqShown, eqAdded }),
|
||||
);
|
||||
const eqHandle = await json('__gm.eq.handleAt(0)');
|
||||
const eqTo = await json('__gm.eq.pointAt(4000, -6)');
|
||||
await drag(eqHandle.x, eqHandle.y, eqTo.x, eqTo.y);
|
||||
const eqDragged = (await json('__gm.eq.bands()'))[0];
|
||||
await press('z', { ctrl: true });
|
||||
const eqUndone = (await json('__gm.eq.bands()'))[0];
|
||||
await press('z', { ctrl: true, shift: true });
|
||||
const eqHandleBack = await json('__gm.eq.handleAt(0)');
|
||||
await t.wheel(eqHandleBack.x, eqHandleBack.y, -100);
|
||||
const eqWheeled = (await json('__gm.eq.bands()'))[0];
|
||||
check(
|
||||
'dragging the band moves it as one undo step, and the wheel narrows it',
|
||||
bandNear(eqDragged, 4000, -6) &&
|
||||
bandNear(eqUndone, 1000, 6) &&
|
||||
bandNear(eqWheeled, 4000, -6) &&
|
||||
eqWheeled.q > 1,
|
||||
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
|
||||
);
|
||||
await shot('34-parametric-eq');
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
const eqBefore = await stats();
|
||||
await click(fxPlayAt.x, fxPlayAt.y);
|
||||
const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started);
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
const eqClose = await json('__gm.eq.closeAt()');
|
||||
await click(eqClose.x, eqClose.y);
|
||||
check(
|
||||
'the voice passes the gain and the band, and the editor closes by its button',
|
||||
eqHeard.last?.stages === 2 && (await json('__gm.eq.shown()')) === null,
|
||||
JSON.stringify(eqHeard.last),
|
||||
);
|
||||
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-eq');
|
||||
graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id));
|
||||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
|
||||
Reference in new issue
Block a user