/** * The curve of a Parametric EQ (#367) and of a Filter (#385): what its bands do together, worked out * as Web Audio plays them (`parametricEq.ts`). A band is dragged for frequency and gain and wheeled * for its Q. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the * tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what * passes the node may be drawn behind the curve (#374). */ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js'; import { dbToY, type EqArea, EQ_RANGES, eqCurvePoints, fillShapes, frequencyToX, hasGain, hasQ, MAX_EQ_BANDS, type PassBand, xToFrequency, yToDb, } from '../../core/soundfx/parametricEq'; import type { Spectrum } from '../../viewport/spectrum'; import { crispText, pressCount, Theme, textStyle, Widget } from '../../ui'; import { ViewTween } from '../viewTween'; import { apartLabels, bandAt, EQ_GRID_FREQUENCIES, eqGridDb, handleOf, SPECTRUM_FLOOR_DB, spectrumPeak, spectrumYs, wheeledQ, wheeledRange, } from './eqView'; /** The least the curve shrinks to; otherwise it takes what its panel leaves it. */ const MIN_CURVE_WIDTH = 160; const MIN_CURVE_HEIGHT = 80; const HANDLE_RADIUS = 7; const GRAB_RADIUS = HANDLE_RADIUS + 4; /** Pixels of travel before a press on a band becomes a drag. */ const DRAG_THRESHOLD = 3; /** Points the curve is drawn through, evenly in octaves. */ const CURVE_STEPS = 320; const BAND_COLORS = [ 0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a, ]; /** Pixels between the points the spectrum is drawn through (#374). */ const SPECTRUM_STEP = 2; /** Pixels kept between two labels on an axis; one that comes closer is left out (#385). */ const LABEL_GAP = 4; /** What the curve asks of the panel around it. */ export interface CurveHost { bands(): readonly PassBand[]; range(): number; zoom(range: number): void; readonly picked: number; pick(index: number): void; /** A band changed as a drag goes, written into the node for a preview. */ drag(index: number, change: Partial): void; /** The drag is over: its undo step. */ drop(): void; /** A band changed as an undo step of its own. */ set(index: number, change: Partial): void; /** A double-click on the free curve; false when the panel takes no band there. */ addBand(frequency: number, gain: number): boolean; /** A right-click on a band; false when the band stays. */ removeBand(index: number): boolean; } export class BandCurve extends Widget { private readonly bg = new Graphics(); private readonly grid = new Graphics(); /** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */ private readonly spectrum = new Graphics(); private readonly lines = new Graphics(); private readonly handles = new Graphics(); private readonly labels: Text[] = []; /** As many as the most lines a range has; those of a range with fewer are hidden. */ private readonly dbLabels: Text[] = []; private readonly numbers: Text[] = []; private dragging: { index: number; startX: number; startY: number; moved: boolean } | null = null; /** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */ private heard: Spectrum | null = null; /** The dB drawn while a zoom is on its way (#377); null draws the node's. */ private shownRange: number | null = null; private readonly tween = new ViewTween({ read: () => ({ scale: 1 / this.range, x: 0, y: 0 }), write: (view) => (this.shownRange = 1 / view.scale), draw: () => { if (!this.tween.moving) this.shownRange = null; this.redraw(); }, }); /** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */ seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 }; constructor(private readonly host: CurveHost) { super(); this.minWidth = MIN_CURVE_WIDTH; this.minHeight = MIN_CURVE_HEIGHT; this.cursor = 'crosshair'; this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles); const muted = textStyle('small').clone(); muted.fill = Theme.colors.textMuted; const onHandle = textStyle('small').clone(); onHandle.fill = Theme.colors.symbolOutline; for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted })); const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length)); for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted })); for (let i = 0; i < MAX_EQ_BANDS; i++) this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle })); for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) { text.eventMode = 'none'; this.addChild(text); } this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e)); this.onPointerRelease(() => this.release()); this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e)); } get area(): EqArea { return { w: this.w, h: this.h, range: this.range }; } private get range(): number { return this.shownRange ?? this.host.range(); } /** The dB drawn and whether a zoom is on its way – the check scripts sample it. */ get zoomShown(): { range: number; moving: boolean } { return { range: this.range, moving: this.tween.moving }; } /** The axis labels drawn, for the checks: those that would run into another are left out. */ get shownLabels(): { frequencies: string[]; dbs: string[] } { const shown = (texts: Text[]) => texts.filter((t) => t.visible).map((t) => t.text); return { frequencies: shown(this.labels), dbs: shown(this.dbLabels) }; } /** Another node: a zoom on its way to the one before ends. */ settle(): void { this.tween.land(); this.shownRange = null; } /** Where a band's handle is on screen – the check scripts aim with it. */ handleGlobal(index: number): { x: number; y: number } | null { const band = this.host.bands()[index]; if (!band) return null; const at = this.toGlobal(handleOf(band, this.area)); return { x: at.x, y: at.y }; } /** Where a frequency and a gain are on screen, at the graph's zoom in the node. */ pointGlobal(frequency: number, db: number): { x: number; y: number } { const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) }); return { x: at.x, y: at.y }; } private local(e: FederatedPointerEvent): { x: number; y: number } { return this.toLocal(e.global); } private press(e: FederatedPointerEvent): void { const at = this.local(e); const bands = this.host.bands(); const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); if (e.button === 2) { if (index >= 0 && this.host.removeBand(index)) e.stopPropagation(); return; } if (e.button !== 0) return; // In the node a press on the curve is the curve's: it neither picks nor moves the node. e.stopPropagation(); if (index >= 0) { this.host.pick(index); this.dragging = { index, startX: e.global.x, startY: e.global.y, moved: false }; return; } // A double-click on the free curve may put a band there; a single one lets go of the picked one. if (pressCount(e, this) === 2) { const frequency = Math.round(xToFrequency(at.x, this.area)); if (this.host.addBand(frequency, Math.round(yToDb(at.y, this.area) * 10) / 10)) return; } this.host.pick(-1); } private move(e: FederatedPointerEvent): void { const drag = this.dragging; if (!drag) return; if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return; drag.moved = true; const at = this.local(e); const band = this.host.bands()[drag.index]; if (!band) return; const frequency = Math.round(xToFrequency(at.x, this.area)); const gain = Math.round(yToDb(at.y, this.area) * 10) / 10; this.host.drag(drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }); } private release(): void { const drag = this.dragging; this.dragging = null; if (drag?.moved) this.host.drop(); } /** * The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in * the tab: in the node the graph takes every wheel before the curve sees it (#383). */ private wheel(e: FederatedWheelEvent): void { if (e.deltaY === 0) return; const at = this.toLocal(e.global); const bands = this.host.bands(); const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); const band = bands[index]; e.stopPropagation(); if (!band) { const range = wheeledRange(this.host.range(), e.deltaY); this.tween.change(() => { this.shownRange = null; this.host.zoom(range); }); return; } if (!hasQ(band.type)) return; this.host.pick(index); this.host.set(index, { q: wheeledQ(band.q, e.deltaY) }); } override redraw(): void { const c = Theme.colors; const area = this.area; this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 }); this.grid.clear(); const frequencySpans = EQ_GRID_FREQUENCIES.map(({ hz }, i) => { const x = Math.round(frequencyToX(hz, area)) + 0.5; this.grid.moveTo(x, 0).lineTo(x, this.h); const text = this.labels[i]!; text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2); return { from: text.x, to: text.x + text.width }; }); apartLabels(frequencySpans, LABEL_GAP).forEach((shown, i) => (this.labels[i]!.visible = shown)); // The lines of the range a zoom is heading to, so they slide to their place with whole numbers. const dbs = eqGridDb(this.host.range()); const dbSpans: { from: number; to: number }[] = []; const placed: Text[] = []; this.dbLabels.forEach((text, i) => { const db = dbs[i]; const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5; text.visible = y > 0 && y < this.h; if (!text.visible || db === undefined) return; this.grid.moveTo(0, y).lineTo(this.w, y); text.text = db > 0 ? `+${db}` : `${db}`; text.position.set(3, y - text.height - 1); dbSpans.push({ from: text.y, to: text.y + text.height }); placed.push(text); }); apartLabels(dbSpans, 0).forEach((shown, i) => (placed[i]!.visible = shown)); this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 }); const zero = Math.round(dbToY(0, area)) + 0.5; this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 }); const bands = this.host.bands(); this.lines.clear(); const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2); const xs = curves.xs; curves.bands.forEach((ys, i) => { trace(this.lines, xs, ys); this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 }); }); for (const shape of fillShapes(xs, curves.sum, zero)) this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 }); trace(this.lines, xs, curves.sum); this.lines.stroke({ color: c.accent, width: 2 }); this.handles.clear(); this.numbers.forEach((text, i) => { const band = bands[i]; text.visible = Boolean(band); if (!band) return; const at = handleOf(band, area); this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]); if (i === this.host.picked) this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 }); text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2)); }); this.drawSpectrum(); } /** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */ showSpectrum(spectrum: Spectrum | null): void { const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz); const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz); const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB; const shown = loud ? spectrum : null; if (!shown && !this.heard) return; this.heard = shown; if (shown) this.seen = { frames: this.seen.frames + 1, peakIn: peakIn?.hz ?? 0, peakOut: peakOut?.hz ?? 0, levelOut: peakOut?.db ?? -Infinity, binHz: shown.binHz, }; this.drawSpectrum(); } get hasSpectrum(): boolean { return this.heard !== null; } private drawSpectrum(): void { this.spectrum.clear(); const heard = this.heard; if (!heard) return; const c = Theme.colors; const count = Math.floor(this.w / SPECTRUM_STEP); const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w); for (const [dbs, color, alpha, line] of [ [heard.in, c.textMuted, 0.12, false], [heard.out, c.portAudio, 0.18, true], ] as const) { const ys = spectrumYs(dbs, heard.binHz, xs, this.area); this.spectrum .poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h]) .fill({ color, alpha }); if (!line) continue; trace(this.spectrum, xs, ys); this.spectrum.stroke({ color, width: 1, alpha: 0.6 }); } } } /** A path through the points. */ function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void { g.moveTo(xs[0]!, ys[0]!); for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!); }