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 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; canvas.width = W * dpr; canvas.height = H * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); 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; 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 ─────────────────────────────────────────────────────────── export default function Equalizer() { const { t } = useTranslation(); const gains = useEqStore(s => s.gains); const enabled = useEqStore(s => s.enabled); 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 [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(() => { const canvas = canvasRef.current; if (!canvas) return; const style = getComputedStyle(document.documentElement); const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)'; const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e'; const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)'; drawCurve(canvas, gains, accent, bg, text); }, [gains, theme]); useEffect(() => { redraw(); }, [redraw]); useEffect(() => { const ro = new ResizeObserver(redraw); if (canvasRef.current) ro.observe(canvasRef.current); 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 handleSave = () => { const name = saveName.trim(); if (!name) return; saveCustomPreset(name); setSaveName(''); setShowSave(false); }; return (
{/* Controls bar */}
applyPreset(v)} options={[ ...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []), ...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })), ...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })), ]} /> {isCustomSaved && ( )}
{showSave && (
setSaveName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSave()} autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }} />
)} {/* 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 */}
{/* Frequency response */} {/* Fader area */}
{/* dB scale */}
{[12, 6, 0, -6, -12].map(db => ( {db > 0 ? `+${db}` : db} ))}
{/* Bands */} {EQ_BANDS.map((band, i) => (
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
setBandGain(i, v)} />
{band.label}
))}
{/* Pre-gain row */}
{t('settings.eqPreGain')} setPreGain(parseFloat(e.target.value))} /> {preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB {preGain !== 0 && ( )}
); }