Files
Psychotoxical-psysonic/src/utils/audio/eqCurve.ts
T
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00

128 lines
4.2 KiB
TypeScript

import { EQ_BANDS } from '../../store/eqStore';
// ─── Frequency response canvas ────────────────────────────────────────────────
const SAMPLE_RATE = 44100;
const EQ_Q = 1.41;
export function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
if (Math.abs(gainDb) < 0.01) return 0;
const w0 = (2 * Math.PI * centerHz) / sampleRate;
const A = Math.pow(10, gainDb / 40);
const alpha = Math.sin(w0) / (2 * EQ_Q);
const b0 = 1 + alpha * A;
const b1 = -2 * Math.cos(w0);
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
const a1 = -2 * Math.cos(w0);
const a2 = 1 - alpha / A;
const w = (2 * Math.PI * freq) / sampleRate;
const cosW = Math.cos(w), sinW = Math.sin(w);
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
const numRe = b0 + b1 * cosW + b2 * cos2W;
const numIm = - b1 * sinW - b2 * sin2W;
const denRe = a0 + a1 * cosW + a2 * cos2W;
const denIm = - a1 * sinW - a2 * sin2W;
const numMag2 = numRe * numRe + numIm * numIm;
const denMag2 = denRe * denRe + denIm * denIm;
return 10 * Math.log10(numMag2 / denMag2);
}
export function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
const fMin = 20, fMax = 20000;
const dbMin = -13, dbMax = 13;
const padL = 36, padR = 8, padT = 8, padB = 1;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
// Canvas hidden or not laid out yet (e.g. inside a collapsed <details> on macOS WebKit).
// Bail before allocating an invalid back-buffer; ResizeObserver redraws when the canvas
// gets real dimensions.
if (innerW <= 0 || innerH <= 0) return;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
const freqToX = (f: number) =>
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
const dbToY = (db: number) =>
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, W, H);
// Grid: dB lines
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
[-12, -6, 0, 6, 12].forEach(db => {
const y = dbToY(db);
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
});
// Grid: frequency lines
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
const x = freqToX(f);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(x, padT);
ctx.lineTo(x, H - padB);
ctx.stroke();
});
// Zero line (brighter)
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, dbToY(0));
ctx.lineTo(W - padR, dbToY(0));
ctx.stroke();
// Frequency response curve
const points: [number, number][] = [];
const steps = innerW * 2;
for (let i = 0; i <= steps; i++) {
const f = fMin * Math.pow(fMax / fMin, i / steps);
let totalDb = 0;
for (let band = 0; band < 10; band++) {
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
}
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
points.push([freqToX(f), dbToY(totalDb)]);
}
// Fill under curve
const grad = ctx.createLinearGradient(0, padT, 0, H);
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
ctx.beginPath();
ctx.moveTo(points[0][0], dbToY(0));
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo(points[points.length - 1][0], dbToY(0));
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Curve line
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.8;
ctx.stroke();
}