From 1f13794b0cc371195e625fd5711e69b7646b7501 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 12:00:43 +0200 Subject: [PATCH] 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 --- src/core/soundfx/nodes.ts | 29 ++ src/core/soundfx/parametricEq.ts | 215 ++++++++++++ src/editor/debug/graphHooks.ts | 30 ++ src/editor/graph/GraphCanvas.ts | 6 + src/editor/soundfx/EqEditor.ts | 554 +++++++++++++++++++++++++++++++ src/editor/soundfx/eqView.ts | 88 +++++ tests/core/parametric-eq.test.ts | 110 ++++++ tests/core/soundfx.test.ts | 12 +- tools/e2e/34-sound.mjs | 65 ++++ 9 files changed, 1105 insertions(+), 4 deletions(-) create mode 100644 src/core/soundfx/parametricEq.ts create mode 100644 src/editor/soundfx/EqEditor.ts create mode 100644 src/editor/soundfx/eqView.ts create mode 100644 tests/core/parametric-eq.test.ts diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index fce3efd3..47ba119a 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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, diff --git a/src/core/soundfx/parametricEq.ts b/src/core/soundfx/parametricEq.ts new file mode 100644 index 00000000..4035784e --- /dev/null +++ b/src/core/soundfx/parametricEq.ts @@ -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; +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 = new Set(['lowshelf', 'highshelf', 'peaking']); +const SLOPED: ReadonlySet = 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[] { + 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); +} diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 6e79b028..89313503 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 371cda95..ccf6a291 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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); diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts new file mode 100644 index 00000000..ccf5182f --- /dev/null +++ b/src/editor/soundfx/EqEditor.ts @@ -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(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) => ({ + 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): void { + if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change)); + } + + private commitPicked(change: Partial): 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; +} diff --git a/src/editor/soundfx/eqView.ts b/src/editor/soundfx/eqView.ts new file mode 100644 index 00000000..b4cbc1f1 --- /dev/null +++ b/src/editor/soundfx/eqView.ts @@ -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]; diff --git a/tests/core/parametric-eq.test.ts b/tests/core/parametric-eq.test.ts new file mode 100644 index 00000000..1f12424f --- /dev/null +++ b/tests/core/parametric-eq.test.ts @@ -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 => ({ + 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]]); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index aa6a5f7c..4a506354 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -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 }[]) { +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 = {}) => + 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, diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 1c43a9d7..0e9d1c75 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;