From cca000d3af2e1f817753ebb06468a63aca4cab97 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 11:57:12 +0200 Subject: [PATCH] =?UTF-8?q?refactor(equalizer):=20split=20Equalizer.tsx=20?= =?UTF-8?q?508=20=E2=86=92=20166=20LOC=20across=206=20files=20(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the canvas frequency-response math, the custom vertical fader, the AutoEQ parser, and the AutoEQ search panel out of Equalizer.tsx: - utils/eqCurve.ts biquadPeakResponse + drawCurve - utils/autoEqParse.ts AutoEq types + parseFixedBandEqString - components/equalizer/VerticalFader.tsx - hooks/useAutoEq.ts AutoEQ search/apply state - components/equalizer/AutoEqSection.tsx Pure code-move, no behaviour change. Both call sites (AudioTab, PlayerBar) default-import Equalizer unchanged. --- src/components/Equalizer.tsx | 356 +-------------------- src/components/equalizer/AutoEqSection.tsx | 73 +++++ src/components/equalizer/VerticalFader.tsx | 58 ++++ src/hooks/useAutoEq.ts | 93 ++++++ src/utils/autoEqParse.ts | 24 ++ src/utils/eqCurve.ts | 127 ++++++++ 6 files changed, 382 insertions(+), 349 deletions(-) create mode 100644 src/components/equalizer/AutoEqSection.tsx create mode 100644 src/components/equalizer/VerticalFader.tsx create mode 100644 src/hooks/useAutoEq.ts create mode 100644 src/utils/autoEqParse.ts create mode 100644 src/utils/eqCurve.ts diff --git a/src/components/Equalizer.tsx b/src/components/Equalizer.tsx index a8629f10..6c3c1e18 100644 --- a/src/components/Equalizer.tsx +++ b/src/components/Equalizer.tsx @@ -1,221 +1,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; +import { Save, Trash2, RotateCcw } from 'lucide-react'; import CustomSelect from './CustomSelect'; import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore'; import { useThemeStore } from '../store/themeStore'; - -// ─── Frequency response canvas ──────────────────────────────────────────────── - -const SAMPLE_RATE = 44100; -const EQ_Q = 1.41; - -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); -} - -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
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(); -} - -// ─── Custom vertical fader (no native range input) ──────────────────────────── - -const GAIN_MIN = -12, GAIN_MAX = 12; - -interface FaderProps { - value: number; - disabled: boolean; - onChange: (v: number) => void; -} - -function VerticalFader({ value, disabled, onChange }: FaderProps) { - const trackRef = useRef(null); - const dragging = useRef(false); - - const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom - const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN); - - const updateFromY = useCallback((clientY: number) => { - const el = trackRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); - const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB - onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain))); - }, [onChange]); - - const onPointerDown = (e: React.PointerEvent) => { - if (disabled) return; - dragging.current = true; - (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); - updateFromY(e.clientY); - }; - - const onPointerMove = (e: React.PointerEvent) => { - if (!dragging.current || disabled) return; - updateFromY(e.clientY); - }; - - const onPointerUp = () => { dragging.current = false; }; - - const thumbPct = gainToPct(value) * 100; - - return ( -
-
-
-
- ); -} - -// ─── AutoEQ helpers ─────────────────────────────────────────────────────────── - -interface AutoEqVariant { form: string; rig: string | null; source: string; } -interface AutoEqResult { name: string; source: string; rig: string | null; form: string; } - - -/** Parses AutoEQ FixedBandEQ.txt format. - * Expected lines: - * Preamp: -5.5 dB - * Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41 - * ... - * Returns all 10 band gains as exact floats and the preamp value. - */ -function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } { - const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i); - const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0; - - const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)]; - allFilters.slice(0, 10).forEach((m, i) => { - gains[i] = parseFloat(m[1]); - }); - - return { gains, preamp }; -} - -// ─── Main component ─────────────────────────────────────────────────────────── +import { drawCurve } from '../utils/eqCurve'; +import VerticalFader from './equalizer/VerticalFader'; +import AutoEqSection from './equalizer/AutoEqSection'; export default function Equalizer() { const { t } = useTranslation(); @@ -224,22 +15,12 @@ export default function Equalizer() { const preGain = useEqStore(s => s.preGain); const activePreset = useEqStore(s => s.activePreset); const customPresets = useEqStore(s => s.customPresets); - const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore(); + const { setBandGain, setEnabled, setPreGain, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore(); const [saveName, setSaveName] = useState(''); const [showSave, setShowSave] = useState(false); const canvasRef = useRef(null); - // AutoEQ state - const [autoEqOpen, setAutoEqOpen] = useState(false); - const [autoEqQuery, setAutoEqQuery] = useState(''); - const [autoEqResults, setAutoEqResults] = useState([]); - const [autoEqLoading, setAutoEqLoading] = useState(false); - const [autoEqError, setAutoEqError] = useState(null); - const [autoEqApplied, setAutoEqApplied] = useState(null); - const [entriesLoading, setEntriesLoading] = useState(false); - const entriesCacheRef = useRef | null>(null); - const theme = useThemeStore(s => s.theme); const redraw = useCallback(() => { @@ -260,65 +41,8 @@ export default function Equalizer() { return () => ro.disconnect(); }, [redraw]); - // AutoEQ: load entries index lazily when section opens, then filter client-side - async function ensureEntries() { - if (entriesCacheRef.current) return; - setEntriesLoading(true); - setAutoEqError(null); - try { - const json = await invoke('autoeq_entries'); - entriesCacheRef.current = JSON.parse(json); - } catch (e: unknown) { - setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError')); - } finally { - setEntriesLoading(false); - } - } - - useEffect(() => { - const q = autoEqQuery.trim().toLowerCase(); - if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; } - const flat: AutoEqResult[] = []; - for (const [name, variants] of Object.entries(entriesCacheRef.current)) { - if (!name.toLowerCase().includes(q)) continue; - for (const v of variants) { - flat.push({ name, source: v.source, rig: v.rig, form: v.form }); - if (flat.length >= 20) break; - } - if (flat.length >= 20) break; - } - setAutoEqResults(flat); - // entriesLoading in deps: re-runs after entries finish loading so a query typed - // during loading produces results immediately without needing a re-type. - }, [autoEqQuery, entriesLoading]); - - async function applyAutoEqResult(result: AutoEqResult) { - setAutoEqLoading(true); - setAutoEqError(null); - try { - const text = await invoke('autoeq_fetch_profile', { - name: result.name, - source: result.source, - rig: result.rig ?? null, - form: result.form, - }); - if (!text) throw new Error(t('settings.eqAutoEqFetchError')); - const { gains: newGains, preamp } = parseFixedBandEqString(text); - applyAutoEq(result.name, newGains, preamp); - setAutoEqApplied(result.name); - setAutoEqQuery(''); - setAutoEqResults([]); - setTimeout(() => setAutoEqApplied(null), 3000); - } catch (e: unknown) { - setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError')); - } finally { - setAutoEqLoading(false); - } - } - - const allPresets = [...BUILTIN_PRESETS, ...customPresets]; - const selectValue = activePreset ?? '__custom__'; const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset); + const selectValue = activePreset ?? '__custom__'; const handleSave = () => { const name = saveName.trim(); @@ -379,73 +103,7 @@ export default function Equalizer() {
)} - {/* AutoEQ section */} -
- - - {autoEqOpen && ( -
-
- { setAutoEqQuery(e.target.value); setAutoEqError(null); }} - autoFocus - style={{ flex: 1, padding: '6px 12px', fontSize: 13 }} - /> - {autoEqQuery && ( - - )} -
- - {(entriesLoading || autoEqLoading) && ( -
{t('settings.eqAutoEqSearching')}
- )} - {autoEqError && ( -
{autoEqError}
- )} - {autoEqApplied && ( -
✓ {autoEqApplied}
- )} - - {autoEqResults.length > 0 && ( -
- {autoEqResults.map((r, i) => ( - - ))} -
- )} - - {!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && ( -
{t('settings.eqAutoEqNoResults')}
- )} -
- )} -
+ {/* EQ panel */}
diff --git a/src/components/equalizer/AutoEqSection.tsx b/src/components/equalizer/AutoEqSection.tsx new file mode 100644 index 00000000..0a390dfa --- /dev/null +++ b/src/components/equalizer/AutoEqSection.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next'; +import { Search, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { useAutoEq } from '../../hooks/useAutoEq'; + +/** Collapsible AutoEQ search panel inside the Equalizer. Owns its own search + * state via {@link useAutoEq}. */ +export default function AutoEqSection() { + const { t } = useTranslation(); + const { + autoEqOpen, autoEqQuery, autoEqResults, autoEqLoading, autoEqError, autoEqApplied, + entriesLoading, setAutoEqQuery, setAutoEqError, setAutoEqResults, + applyAutoEqResult, toggleOpen, + } = useAutoEq(); + + return ( +
+ + + {autoEqOpen && ( +
+
+ { setAutoEqQuery(e.target.value); setAutoEqError(null); }} + autoFocus + style={{ flex: 1, padding: '6px 12px', fontSize: 13 }} + /> + {autoEqQuery && ( + + )} +
+ + {(entriesLoading || autoEqLoading) && ( +
{t('settings.eqAutoEqSearching')}
+ )} + {autoEqError && ( +
{autoEqError}
+ )} + {autoEqApplied && ( +
✓ {autoEqApplied}
+ )} + + {autoEqResults.length > 0 && ( +
+ {autoEqResults.map((r, i) => ( + + ))} +
+ )} + + {!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && ( +
{t('settings.eqAutoEqNoResults')}
+ )} +
+ )} +
+ ); +} diff --git a/src/components/equalizer/VerticalFader.tsx b/src/components/equalizer/VerticalFader.tsx new file mode 100644 index 00000000..e95d0677 --- /dev/null +++ b/src/components/equalizer/VerticalFader.tsx @@ -0,0 +1,58 @@ +import { useRef, useCallback } from 'react'; + +// ─── Custom vertical fader (no native range input) ──────────────────────────── + +const GAIN_MIN = -12, GAIN_MAX = 12; + +interface FaderProps { + value: number; + disabled: boolean; + onChange: (v: number) => void; +} + +export default function VerticalFader({ value, disabled, onChange }: FaderProps) { + const trackRef = useRef(null); + const dragging = useRef(false); + + const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom + const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN); + + const updateFromY = useCallback((clientY: number) => { + const el = trackRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB + onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain))); + }, [onChange]); + + const onPointerDown = (e: React.PointerEvent) => { + if (disabled) return; + dragging.current = true; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + updateFromY(e.clientY); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging.current || disabled) return; + updateFromY(e.clientY); + }; + + const onPointerUp = () => { dragging.current = false; }; + + const thumbPct = gainToPct(value) * 100; + + return ( +
+
+
+
+ ); +} diff --git a/src/hooks/useAutoEq.ts b/src/hooks/useAutoEq.ts new file mode 100644 index 00000000..6d091ec6 --- /dev/null +++ b/src/hooks/useAutoEq.ts @@ -0,0 +1,93 @@ +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; +import { useEqStore } from '../store/eqStore'; +import { parseFixedBandEqString, type AutoEqVariant, type AutoEqResult } from '../utils/autoEqParse'; + +/** AutoEQ search/apply state for the Equalizer. Loads the entries index lazily + * when the section opens, filters client-side, and applies fetched profiles via + * the EQ store. */ +export function useAutoEq() { + const { t } = useTranslation(); + const applyAutoEq = useEqStore(s => s.applyAutoEq); + + const [autoEqOpen, setAutoEqOpen] = useState(false); + const [autoEqQuery, setAutoEqQuery] = useState(''); + const [autoEqResults, setAutoEqResults] = useState([]); + const [autoEqLoading, setAutoEqLoading] = useState(false); + const [autoEqError, setAutoEqError] = useState(null); + const [autoEqApplied, setAutoEqApplied] = useState(null); + const [entriesLoading, setEntriesLoading] = useState(false); + const entriesCacheRef = useRef | null>(null); + + // AutoEQ: load entries index lazily when section opens, then filter client-side + async function ensureEntries() { + if (entriesCacheRef.current) return; + setEntriesLoading(true); + setAutoEqError(null); + try { + const json = await invoke('autoeq_entries'); + entriesCacheRef.current = JSON.parse(json); + } catch (e: unknown) { + setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError')); + } finally { + setEntriesLoading(false); + } + } + + useEffect(() => { + const q = autoEqQuery.trim().toLowerCase(); + if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; } + const flat: AutoEqResult[] = []; + for (const [name, variants] of Object.entries(entriesCacheRef.current)) { + if (!name.toLowerCase().includes(q)) continue; + for (const v of variants) { + flat.push({ name, source: v.source, rig: v.rig, form: v.form }); + if (flat.length >= 20) break; + } + if (flat.length >= 20) break; + } + setAutoEqResults(flat); + // entriesLoading in deps: re-runs after entries finish loading so a query typed + // during loading produces results immediately without needing a re-type. + }, [autoEqQuery, entriesLoading]); + + async function applyAutoEqResult(result: AutoEqResult) { + setAutoEqLoading(true); + setAutoEqError(null); + try { + const text = await invoke('autoeq_fetch_profile', { + name: result.name, + source: result.source, + rig: result.rig ?? null, + form: result.form, + }); + if (!text) throw new Error(t('settings.eqAutoEqFetchError')); + const { gains: newGains, preamp } = parseFixedBandEqString(text); + applyAutoEq(result.name, newGains, preamp); + setAutoEqApplied(result.name); + setAutoEqQuery(''); + setAutoEqResults([]); + setTimeout(() => setAutoEqApplied(null), 3000); + } catch (e: unknown) { + setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError')); + } finally { + setAutoEqLoading(false); + } + } + + const toggleOpen = () => { + const opening = !autoEqOpen; + setAutoEqOpen(opening); + setAutoEqQuery(''); + setAutoEqResults([]); + setAutoEqError(null); + if (opening) ensureEntries(); + }; + + return { + autoEqOpen, autoEqQuery, autoEqResults, autoEqLoading, autoEqError, autoEqApplied, + entriesLoading, setAutoEqQuery, setAutoEqError, setAutoEqResults, + applyAutoEqResult, toggleOpen, + }; +} diff --git a/src/utils/autoEqParse.ts b/src/utils/autoEqParse.ts new file mode 100644 index 00000000..ed57b599 --- /dev/null +++ b/src/utils/autoEqParse.ts @@ -0,0 +1,24 @@ +// ─── AutoEQ helpers ─────────────────────────────────────────────────────────── + +export interface AutoEqVariant { form: string; rig: string | null; source: string; } +export interface AutoEqResult { name: string; source: string; rig: string | null; form: string; } + +/** Parses AutoEQ FixedBandEQ.txt format. + * Expected lines: + * Preamp: -5.5 dB + * Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41 + * ... + * Returns all 10 band gains as exact floats and the preamp value. + */ +export function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } { + const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i); + const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0; + + const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)]; + allFilters.slice(0, 10).forEach((m, i) => { + gains[i] = parseFloat(m[1]); + }); + + return { gains, preamp }; +} diff --git a/src/utils/eqCurve.ts b/src/utils/eqCurve.ts new file mode 100644 index 00000000..f564b109 --- /dev/null +++ b/src/utils/eqCurve.ts @@ -0,0 +1,127 @@ +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
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(); +}