export const GEQ_BAND_HZ = [ 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000, ] as const; /** Peaking-filter magnitude response (dB) of a single band at a given frequency. */ export function geqPeakResponseDb( freq: number, bandFreq: number, gainDb: number, q: number ): number { if (gainDb === 0) return 0; const ratio = freq / bandFreq - bandFreq / freq; return gainDb / (1 + (q * ratio) ** 2); } /** Sum of all bands' contributions at a given frequency. */ export function geqTotalCurveDb( freq: number, values: number[], bandHz: readonly number[] = GEQ_BAND_HZ, q: number ): number { let sum = 0; for (let i = 0; i < values.length; i++) { sum += geqPeakResponseDb(freq, bandHz[i], values[i], q); } return sum; } /** Log-spaced sample points from xMin to xMax, for a pixel-smooth curve on a log x-axis. */ export function logSpace(xMin: number, xMax: number, steps: number): number[] { const logMin = Math.log10(xMin); const logMax = Math.log10(xMax); const out: number[] = []; for (let s = 0; s <= steps; s++) { out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps)); } return out; } export type GeqCurveColors = { strokeColor: string; fillAbove: string; fillAboveFade: string; fillBelowFade: string; fillBelow: string; }; export const DEFAULT_GEQ_CURVE_COLORS: GeqCurveColors = { strokeColor: '#e5e7eb', fillAbove: 'rgba(74, 222, 128, 0.35)', fillAboveFade: 'rgba(74, 222, 128, 0.08)', fillBelowFade: 'rgba(248, 113, 113, 0.08)', fillBelow: 'rgba(248, 113, 113, 0.35)', }; export type GeqCurveOptions = { values: number[]; bandHz?: readonly number[]; q: number; xMin: number; xMax: number; yMin: number; yMax: number; steps?: number; colors?: GeqCurveColors; lineWidth?: number; dimmed?: boolean; }; export function buildGeqCurveDraw({ values, bandHz = GEQ_BAND_HZ, q, xMin, xMax, yMin, yMax, steps = 500, colors = DEFAULT_GEQ_CURVE_COLORS, lineWidth = 2, dimmed = false, }: GeqCurveOptions) { return ({ ctx, toPx, }: { ctx: CanvasRenderingContext2D; toPx: (x: number, y: number) => [number, number]; }) => { const freqs = logSpace(xMin, xMax, steps); const points = freqs.map((freq) => { const gain = geqTotalCurveDb(freq, values, bandHz, q); return toPx(freq, gain); }); ctx.save(); if (dimmed) ctx.globalAlpha = 0.25; // NEW: fade everything drawn below // Fill area under the curve down to 0 dB ctx.beginPath(); points.forEach(([xPx, yPx], i) => { if (i === 0) ctx.moveTo(xPx, yPx); else ctx.lineTo(xPx, yPx); }); const [xEndPx, zeroYPx] = toPx(xMax, 0); const [xStartPx] = toPx(xMin, 0); ctx.lineTo(xEndPx, zeroYPx); ctx.lineTo(xStartPx, zeroYPx); ctx.closePath(); const [, topPx] = toPx(xMin, yMax); const [, bottomPx] = toPx(xMin, yMin); const gradient = ctx.createLinearGradient(0, topPx, 0, bottomPx); gradient.addColorStop(0, colors.fillAbove); gradient.addColorStop(0.5, colors.fillAboveFade); gradient.addColorStop(0.5, colors.fillBelowFade); gradient.addColorStop(1, colors.fillBelow); ctx.fillStyle = gradient; ctx.fill(); // Stroke the curve on top ctx.beginPath(); points.forEach(([xPx, yPx], i) => { if (i === 0) ctx.moveTo(xPx, yPx); else ctx.lineTo(xPx, yPx); }); ctx.strokeStyle = colors.strokeColor; ctx.lineWidth = lineWidth; if (dimmed) ctx.setLineDash([6, 4]); // dashed = "not actually applied" ctx.stroke(); ctx.restore(); }; }