diff --git a/index.html b/index.html index 76dd38bd..fb8b27a0 100644 --- a/index.html +++ b/index.html @@ -4720,6 +4720,79 @@

Speaker EQ - Room Correction

+
+ +
+
+ + + + +
diff --git a/js/audio-context.js b/js/audio-context.js index 80ac0fc4..8380f675 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -511,6 +511,31 @@ class AudioContextManager { this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.7; + // High-resolution spectrum analyser for EQ graph overlay. + // Large fftSize gives ~5.9 Hz/bin at 48 kHz — critical for bass detail. + // Settings.js does its own time-averaging, so smoothing is disabled here. + this.spectrumAnalyser = this.audioContext.createAnalyser(); + try { + this.spectrumAnalyser.fftSize = 8192; + } catch { + // Older browsers cap fftSize — fall back gracefully + try { + this.spectrumAnalyser.fftSize = 4096; + } catch { + this.spectrumAnalyser.fftSize = 2048; + } + } + this.spectrumAnalyser.smoothingTimeConstant = 0; + // Widen decibel bounds to cover the overlay's Range Lo..Hi span so + // getFloatFrequencyData isn't clamped at ~[-100, -30] defaults and + // the Lo knob can reach -180 dBFS without silent saturation. + try { + this.spectrumAnalyser.minDecibels = -180; + this.spectrumAnalyser.maxDecibels = 0; + } catch { + /* some engines reject asymmetric ranges */ + } + this._createEQ(); this._createGraphicEQ(); this._createMSNodes(); @@ -609,6 +634,13 @@ class AudioContextManager { } this.analyser.connect(this.volumeNode); this.volumeNode.connect(this.audioContext.destination); + // Parallel tap for the hi-res EQ spectrum overlay (dead-end is fine + // for AnalyserNode — it only needs input to sample from). + try { + if (this.spectrumAnalyser) this.analyser.connect(this.spectrumAnalyser); + } catch { + /* ignore */ + } }; try { @@ -656,6 +688,7 @@ class AudioContextManager { this.geqFilters.forEach(safeDisconnect); safeDisconnect(this.geqOutputNode); safeDisconnect(this.analyser); + safeDisconnect(this.spectrumAnalyser); safeDisconnect(this.volumeNode); let lastNode = this.source; @@ -776,6 +809,15 @@ class AudioContextManager { return this.analyser; } + /** + * Get the dedicated high-resolution analyser for the EQ spectrum overlay. + * Returns null when no dedicated node exists so callers never mutate the + * shared visualizer analyser (which would desync its cached binCount). + */ + getSpectrumAnalyser() { + return this.spectrumAnalyser || null; + } + /** * Get the audio context */ diff --git a/js/settings.js b/js/settings.js index 58e04d28..b274a010 100644 --- a/js/settings.js +++ b/js/settings.js @@ -63,6 +63,9 @@ async function getButterchurnPresets(...args) { let _autoeqIndex = []; let _graphAbortController = null; let _graphResizeObserver = null; +// Persisted across initializeSettings() re-runs so listeners from a previous +// call can be torn down before fresh ones register. +let _spectrumListenersAbort = null; export async function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -2019,6 +2022,324 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); }; + // Spectrum overlay state (themed live analyser tinted by |EQ gain|) + let spectrumOverlayEnabled = false; + try { + spectrumOverlayEnabled = localStorage.getItem('autoeq-spectrum-overlay') === '1'; + } catch { + /* ignore */ + } + let _spectrumRafId = null; + let _spectrumData = null; + let _spectrumEma = null; + let _spectrumLastTs = 0; + // Display range after slope compensation. Hi is hard-fixed at -15 dBFS + // (never user-adjustable, never restored from storage). Only Lo is + // persisted so users can tune their display floor. + const spectrumRangeHi = -15; + let spectrumRangeLo = -103; + const SPECTRUM_RANGE_LO_MIN = -180; + const SPECTRUM_RANGE_LO_MAX = -40; + try { + const l = parseFloat(localStorage.getItem('autoeq-spectrum-range-lo')); + if (Number.isFinite(l)) spectrumRangeLo = Math.max(SPECTRUM_RANGE_LO_MIN, Math.min(SPECTRUM_RANGE_LO_MAX, l)); + } catch { + /* ignore */ + } + // SPAN-style display: pink-tilt compensation so a flat mix reads flat + const SPECTRUM_SLOPE_DB_PER_OCT = 4.0; + const SPECTRUM_SLOPE_PIVOT_HZ = 1000; + const SPECTRUM_OCTAVE_SMOOTH = 1 / 48; // minimal — keep FFT detail + // Mutable speed / FFT presets (configurable via in-graph pills) + const SPECTRUM_SPEED_PRESETS = { + Fast: 60, + Med: 110, + Slow: 260, + }; + const SPECTRUM_FFT_PRESETS = { + '2K': 2048, + '4K': 4096, + '8K': 8192, + '16K': 16384, + }; + let spectrumSpeedKey = 'Med'; + let spectrumFftKey = '8K'; + try { + const savedSpeed = localStorage.getItem('autoeq-spectrum-speed'); + if (savedSpeed && SPECTRUM_SPEED_PRESETS[savedSpeed]) spectrumSpeedKey = savedSpeed; + const savedFft = localStorage.getItem('autoeq-spectrum-fft'); + if (savedFft && SPECTRUM_FFT_PRESETS[savedFft]) spectrumFftKey = savedFft; + } catch { + /* ignore */ + } + let spectrumTimeAvgMs = SPECTRUM_SPEED_PRESETS[spectrumSpeedKey]; + let spectrumFrozen = false; + + const shouldAnimateSpectrum = () => + spectrumOverlayEnabled && + !spectrumFrozen && + equalizerSettings.isEnabled() && + currentMode !== 'legacy' && + eqContainer?.offsetParent !== null; + + const startSpectrumLoop = () => { + if (_spectrumRafId) return; + const tick = () => { + if (!shouldAnimateSpectrum()) { + _spectrumRafId = null; + scheduleDrawAutoEQGraph(); + return; + } + _spectrumRafId = requestAnimationFrame(tick); + scheduleDrawAutoEQGraph(); + }; + _spectrumRafId = requestAnimationFrame(tick); + }; + + const stopSpectrumLoop = () => { + if (_spectrumRafId) { + cancelAnimationFrame(_spectrumRafId); + _spectrumRafId = null; + } + scheduleDrawAutoEQGraph(); + }; + + const drawSpectrumLayer = (ctx, padLeft, padTop, w, h, sampleRate) => { + // Only use the dedicated spectrum analyser. If unavailable, bail so we + // never mutate fftSize on the shared visualizer node. + let analyser = null; + try { + analyser = audioContextManager?.getSpectrumAnalyser?.() || null; + } catch { + return; + } + if (!analyser) return; + if (!_spectrumData || _spectrumData.length !== analyser.frequencyBinCount) { + _spectrumData = new Float32Array(analyser.frequencyBinCount); + _spectrumEma = null; + } + const binCount = _spectrumData.length; + const nyquist = (analyser.context?.sampleRate || 48000) / 2; + const dbRange = Math.max(1, spectrumRangeHi - spectrumRangeLo); + + if (!_spectrumEma || _spectrumEma.length !== binCount) { + _spectrumEma = new Float32Array(binCount).fill(spectrumRangeLo); + } + + // When held, skip sampling + EMA update so _spectrumEma stays at its + // last value — render below still draws it, so the last frame is frozen. + if (!spectrumFrozen) { + analyser.getFloatFrequencyData(_spectrumData); + const nowTs = performance.now(); + const dtMs = _spectrumLastTs ? Math.max(1, Math.min(100, nowTs - _spectrumLastTs)) : 16; + _spectrumLastTs = nowTs; + const emaAlpha = 1 - Math.exp(-dtMs / spectrumTimeAvgMs); + for (let i = 0; i < binCount; i++) { + const raw = _spectrumData[i]; + const v = Number.isFinite(raw) ? raw : spectrumRangeLo; + _spectrumEma[i] = _spectrumEma[i] * (1 - emaAlpha) + v * emaAlpha; + } + } else { + _spectrumLastTs = 0; // fresh dt on resume, so EMA doesn't jump + } + + // Read themed RGB once per draw + const root = getComputedStyle(document.documentElement); + const rgbStr = (root.getPropertyValue('--highlight-rgb') || '236,72,153').trim(); + const parts = rgbStr.split(',').map((v) => parseInt(v, 10)); + const tr = Number.isFinite(parts[0]) ? parts[0] : 236; + const tg = Number.isFinite(parts[1]) ? parts[1] : 72; + const tb = Number.isFinite(parts[2]) ? parts[2] : 153; + + // Downsample columns so neighboring points are close in freq → quadratic + // curves between them produce a silky outline without aliasing artefacts. + const cols = Math.max(96, Math.min(240, Math.floor(w / 2))); + const step = w / cols; + + // 1/N-octave smoothing half-width (in octaves) + const halfOct = SPECTRUM_OCTAVE_SMOOTH / 2; + const fRatioLo = Math.pow(2, -halfOct); + const fRatioHi = Math.pow(2, halfOct); + const binHz = nyquist / binCount; + + // Returns 0..1 with 1 = spectrumRangeHi after slope compensation, + // 0 = spectrumRangeLo. Applies octave smoothing + +4 dB/oct pink tilt. + // Below ~120 Hz the FFT window is wider than the octave window, so the + // raw bins show as stair-steps — we force a minimum-3-bin average there + // to soften the squared plateaus without affecting treble detail. + const magAt = (freq) => { + const fLo = freq * fRatioLo; + const fHi = freq * fRatioHi; + let iLo = Math.floor(fLo / binHz); + let iHi = Math.ceil(fHi / binHz); + if (iHi < 1) return 0; + // Minimum 3-bin window: prevents same-bin plateaus at low freqs + if (iHi - iLo < 2) { + const center = Math.round(freq / binHz); + iLo = center - 1; + iHi = center + 1; + } + iLo = Math.max(1, iLo); + iHi = Math.min(binCount - 1, iHi); + if (iLo > iHi) iLo = iHi; + let sum = 0; + let count = 0; + for (let i = iLo; i <= iHi; i++) { + const v = _spectrumEma[i]; + if (Number.isFinite(v)) { + sum += v; + count++; + } + } + if (count === 0) return 0; + let db = sum / count; + // Slope compensation (pink-tilt): flat mixes display flat + db += SPECTRUM_SLOPE_DB_PER_OCT * Math.log2(freq / SPECTRUM_SLOPE_PIVOT_HZ); + const norm = (db - spectrumRangeLo) / dbRange; + return Math.max(0, Math.min(1, norm)); + }; + + // Precompute smoothed heights — kernel width scales with how many pixels + // represent one FFT bin at the column's frequency. At low freqs one bin + // spans many pixel columns → wider kernel flattens the stair-steps. + const ys = new Float32Array(cols + 1); + const raw = new Float32Array(cols + 1); + const freqs = new Float32Array(cols + 1); + for (let i = 0; i <= cols; i++) { + const freq = Math.pow(10, ((i * step) / w) * LOG_RANGE + LOG_MIN); + freqs[i] = freq; + raw[i] = magAt(freq); + } + // Per-column kernel radius: if neighbour columns are closer in Hz than + // one FFT bin, multiple columns alias to the same bin → wider kernel + // smooths the plateau. Treble columns span many bins → radius 1. + for (let i = 0; i <= cols; i++) { + const df = Math.max(0.1, freqs[Math.min(cols, i + 1)] - freqs[Math.max(0, i - 1)]) / 2; + const radius = Math.max(1, Math.min(6, Math.round(binHz / df / 2))); + let sum = 0; + let wsum = 0; + const twoSigmaSq = 2 * Math.max(0.7, radius / 2) ** 2; + for (let j = -radius; j <= radius; j++) { + const idx = i + j; + if (idx < 0 || idx > cols) continue; + const kw = Math.exp(-(j * j) / twoSigmaSq); + sum += raw[idx] * kw; + wsum += kw; + } + ys[i] = padTop + h - (sum / wsum) * h; + } + + // Trace the curve once via quadratic midpoints so the outline is smooth + const traceCurve = (closeToBaseline) => { + ctx.beginPath(); + if (closeToBaseline) ctx.moveTo(padLeft, padTop + h); + ctx.lineTo(padLeft, ys[0]); + for (let i = 0; i < cols; i++) { + const x0 = padLeft + i * step; + const x1 = padLeft + (i + 1) * step; + const mx = (x0 + x1) / 2; + const my = (ys[i] + ys[i + 1]) / 2; + ctx.quadraticCurveTo(x0, ys[i], mx, my); + } + ctx.lineTo(padLeft + w, ys[cols]); + if (closeToBaseline) { + ctx.lineTo(padLeft + w, padTop + h); + ctx.closePath(); + } + }; + + ctx.save(); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + + // Body gradient — dense at bottom, dissolving at top + const bodyGrad = ctx.createLinearGradient(0, padTop, 0, padTop + h); + bodyGrad.addColorStop(0, `rgba(${tr},${tg},${tb},0.05)`); + bodyGrad.addColorStop(0.55, `rgba(${tr},${tg},${tb},0.22)`); + bodyGrad.addColorStop(1, `rgba(${tr},${tg},${tb},0.42)`); + traceCurve(true); + ctx.fillStyle = bodyGrad; + ctx.fill(); + + // EQ-response tint — two single-color gradients (white for boost, black + // for cut) so gradient interpolation never drifts through grey and + // muddies the themed colour. Clipped to the spectrum body so the tint + // reads as internal lighting on the waveform. + const bands = getActiveBands() || []; + const anyActive = bands.some((b) => b && b.enabled && Math.abs(b.gain || 0) > 0.2); + if (anyActive) { + const stops = 48; + const TINT_DB_SCALE = 8; // soft-knee saturation point (dB) + const BOOST_MAX_ALPHA = 0.4; + const CUT_MAX_ALPHA = 0.5; + + // Sample EQ response once, split into boost / cut lanes + const boosts = new Float32Array(stops + 1); + const cuts = new Float32Array(stops + 1); + let hasBoost = false; + let hasCut = false; + for (let i = 0; i <= stops; i++) { + const t = i / stops; + const freq = Math.pow(10, t * LOG_RANGE + LOG_MIN); + let eqGain = 0; + for (const band of bands) { + if (band && band.enabled) { + eqGain += calculateBiquadResponse(freq, band, sampleRate); + } + } + // Soft knee past saturation so >10 dB still reads as "more" + const abs = Math.abs(eqGain); + const soft = + abs <= TINT_DB_SCALE + ? abs / TINT_DB_SCALE + : 1 - Math.exp(-(abs - TINT_DB_SCALE) / TINT_DB_SCALE) * 0.5 + 0.5; + const n = Math.min(1, soft); + if (eqGain > 0) { + boosts[i] = n; + hasBoost = true; + } else if (eqGain < 0) { + cuts[i] = n; + hasCut = true; + } + } + + traceCurve(true); + ctx.save(); + ctx.clip(); + + if (hasBoost) { + const bg = ctx.createLinearGradient(padLeft, 0, padLeft + w, 0); + for (let i = 0; i <= stops; i++) { + const a = (boosts[i] * BOOST_MAX_ALPHA).toFixed(3); + bg.addColorStop(i / stops, `rgba(255,255,255,${a})`); + } + ctx.fillStyle = bg; + ctx.fillRect(padLeft, padTop, w, h); + } + if (hasCut) { + const cg = ctx.createLinearGradient(padLeft, 0, padLeft + w, 0); + for (let i = 0; i <= stops; i++) { + const a = (cuts[i] * CUT_MAX_ALPHA).toFixed(3); + cg.addColorStop(i / stops, `rgba(0,0,0,${a})`); + } + ctx.fillStyle = cg; + ctx.fillRect(padLeft, padTop, w, h); + } + ctx.restore(); + } + + // Soft rim glow — +2 px stroke width + traceCurve(false); + ctx.shadowColor = `rgba(${tr},${tg},${tb},0.55)`; + ctx.shadowBlur = 10; + ctx.strokeStyle = `rgba(${tr},${tg},${tb},0.28)`; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.restore(); + }; + const drawAutoEQGraph = () => { if (!autoeqCanvas) return; const activeBands = getActiveBands(); @@ -2040,6 +2361,19 @@ export async function initializeSettings(scrobbler, player, api, ui) { ctx.clearRect(0, 0, rect.width, rect.height); + // Spectrum overlay layer (below grid + curves) + if (spectrumOverlayEnabled) { + const spectrumSampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + drawSpectrumLayer( + ctx, + 40, // padLeft (matches below) + 10, // padTop + rect.width - 40 - 10, + rect.height - 10 - 30, + spectrumSampleRate + ); + } + // dB scale: fixed 75dB center for AutoEQ, 0dB center for Parametric const isParametricMode = currentMode === 'parametric'; const dbCenter = isParametricMode ? 0 : 75; @@ -4243,6 +4577,275 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } + // Spectrum overlay toggle + const spectrumBtn = document.getElementById('eq-spectrum-toggle'); + if (spectrumBtn) { + const shouldRunSpectrumLoop = () => { + return spectrumOverlayEnabled && !document.hidden && spectrumBtn.offsetParent !== null; + }; + const applySpectrumState = () => { + spectrumBtn.classList.toggle('active', spectrumOverlayEnabled); + spectrumBtn.setAttribute('aria-pressed', String(spectrumOverlayEnabled)); + if (shouldRunSpectrumLoop()) startSpectrumLoop(); + else stopSpectrumLoop(); + }; + const onSpectrumVisibilityChange = () => { + applySpectrumState(); + }; + // Re-evaluate when EQ master toggle flips, when mode changes, or when + // the equalizer-container becomes visible again. Without this, the rAF + // loop self-stops via shouldAnimateSpectrum() and never restarts — the + // graph then only redraws from other triggers (~2 fps from stray events). + const reevalSpectrumLoop = () => { + // defer one frame so display:none transitions / mode swaps finish + requestAnimationFrame(applySpectrumState); + }; + + // Tear down listeners from a previous initializeSettings() call so we + // don't accumulate duplicate handlers across re-inits. + if (_spectrumListenersAbort) _spectrumListenersAbort.abort(); + _spectrumListenersAbort = new AbortController(); + const sigOpts = { signal: _spectrumListenersAbort.signal }; + + document.addEventListener('visibilitychange', onSpectrumVisibilityChange, sigOpts); + if (eqToggle) eqToggle.addEventListener('change', reevalSpectrumLoop, sigOpts); + window.addEventListener('equalizer-toggle', reevalSpectrumLoop, sigOpts); + document + .querySelectorAll('.autoeq-mode-btn') + .forEach((b) => b.addEventListener('click', reevalSpectrumLoop, sigOpts)); + + applySpectrumState(); + spectrumBtn.addEventListener('click', () => { + spectrumOverlayEnabled = !spectrumOverlayEnabled; + try { + localStorage.setItem('autoeq-spectrum-overlay', spectrumOverlayEnabled ? '1' : '0'); + } catch { + /* ignore */ + } + applySpectrumState(); + }); + } + + // Range Hi / Range Lo knob pills + const attachRangeKnob = (btn, valueEl, opts) => { + if (!btn || !valueEl) return; + const { min, max, defaultValue, storageKey, get, set } = opts; + const clamp = (v) => Math.max(min, Math.min(max, v)); + + // ARIA: expose as an accessible slider + btn.setAttribute('role', 'slider'); + btn.setAttribute('aria-valuemin', String(min)); + btn.setAttribute('aria-valuemax', String(max)); + btn.setAttribute('tabindex', '0'); + + const render = () => { + const v = Math.round(get()); + valueEl.textContent = String(v); + btn.setAttribute('aria-valuenow', String(v)); + btn.setAttribute('aria-valuetext', `${v} dBFS`); + }; + const persist = () => { + try { + localStorage.setItem(storageKey, String(get())); + } catch { + /* ignore */ + } + }; + render(); + + btn.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -1 : 1; + set(clamp(get() + delta)); + render(); + persist(); + }, + { passive: false } + ); + + btn.addEventListener('dblclick', (e) => { + e.preventDefault(); + set(defaultValue); + render(); + persist(); + }); + + // Keyboard: arrows adjust, Home/End jump to bounds, Shift for coarse + btn.addEventListener('keydown', (e) => { + const coarse = e.shiftKey ? 6 : 1; + let handled = true; + switch (e.key) { + case 'ArrowUp': + case 'ArrowRight': + set(clamp(get() + coarse)); + break; + case 'ArrowDown': + case 'ArrowLeft': + set(clamp(get() - coarse)); + break; + case 'PageUp': + set(clamp(get() + 10)); + break; + case 'PageDown': + set(clamp(get() - 10)); + break; + case 'Home': + set(min); + break; + case 'End': + set(max); + break; + case 'Enter': + case ' ': + set(defaultValue); + break; + default: + handled = false; + } + if (handled) { + e.preventDefault(); + render(); + persist(); + } + }); + + btn.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + try { + btn.setPointerCapture(e.pointerId); + } catch { + /* capture may fail on synthetic events */ + } + btn.classList.add('dragging'); + const startY = e.clientY; + const startVal = get(); + const onMove = (ev) => { + const dy = startY - ev.clientY; + set(clamp(startVal + dy * 0.4)); + render(); + }; + const onUp = (ev) => { + // Guard releasePointerCapture: capture may already be lost + // (e.g. pointercancel fired before pointerup) + try { + if (btn.hasPointerCapture?.(e.pointerId)) { + btn.releasePointerCapture(e.pointerId); + } + } catch { + /* ignore */ + } + btn.classList.remove('dragging'); + btn.removeEventListener('pointermove', onMove); + btn.removeEventListener('pointerup', onUp); + btn.removeEventListener('pointercancel', onUp); + persist(); + ev.preventDefault(); + }; + btn.addEventListener('pointermove', onMove); + btn.addEventListener('pointerup', onUp); + btn.addEventListener('pointercancel', onUp); + }); + }; + + // Hi is hard-fixed — only wire the Lo knob + attachRangeKnob( + document.getElementById('eq-spectrum-range-lo'), + document.getElementById('eq-spectrum-range-lo-value'), + { + min: SPECTRUM_RANGE_LO_MIN, + max: SPECTRUM_RANGE_LO_MAX, + defaultValue: -103, + storageKey: 'autoeq-spectrum-range-lo', + get: () => spectrumRangeLo, + set: (v) => { + spectrumRangeLo = Math.min(spectrumRangeHi - 6, v); + }, + } + ); + + // Hold (pause/play) pill + const holdBtn = document.getElementById('eq-spectrum-hold'); + const holdIconEl = document.getElementById('eq-spectrum-hold-icon'); + const holdValueEl = document.getElementById('eq-spectrum-hold-value'); + const HOLD_ICON_PAUSE = + ''; + const HOLD_ICON_PLAY = ''; + if (holdBtn && holdIconEl && holdValueEl) { + const applyHold = () => { + holdBtn.classList.toggle('active', spectrumFrozen); + holdBtn.setAttribute('aria-pressed', String(spectrumFrozen)); + holdIconEl.innerHTML = spectrumFrozen ? HOLD_ICON_PLAY : HOLD_ICON_PAUSE; + holdValueEl.textContent = spectrumFrozen ? 'Held' : 'Hold'; + }; + applyHold(); + holdBtn.addEventListener('click', () => { + spectrumFrozen = !spectrumFrozen; + applyHold(); + // Re-evaluate the rAF loop: pause when freezing, resume when + // unfreezing (shouldAnimateSpectrum will gate either way). + if (spectrumFrozen) stopSpectrumLoop(); + else startSpectrumLoop(); + }); + } + + // Speed cycle pill + const speedBtn = document.getElementById('eq-spectrum-speed'); + const speedValueEl = document.getElementById('eq-spectrum-speed-value'); + if (speedBtn && speedValueEl) { + const speedKeys = Object.keys(SPECTRUM_SPEED_PRESETS); + const applySpeed = () => { + spectrumTimeAvgMs = SPECTRUM_SPEED_PRESETS[spectrumSpeedKey]; + speedValueEl.textContent = spectrumSpeedKey; + }; + applySpeed(); + speedBtn.addEventListener('click', () => { + const idx = speedKeys.indexOf(spectrumSpeedKey); + spectrumSpeedKey = speedKeys[(idx + 1) % speedKeys.length]; + try { + localStorage.setItem('autoeq-spectrum-speed', spectrumSpeedKey); + } catch { + /* ignore */ + } + applySpeed(); + }); + } + + // FFT cycle pill + const fftBtn = document.getElementById('eq-spectrum-fft'); + const fftValueEl = document.getElementById('eq-spectrum-fft-value'); + if (fftBtn && fftValueEl) { + const fftKeys = Object.keys(SPECTRUM_FFT_PRESETS); + const applyFft = () => { + fftValueEl.textContent = spectrumFftKey; + const size = SPECTRUM_FFT_PRESETS[spectrumFftKey]; + try { + const an = audioContextManager?.getSpectrumAnalyser?.(); + if (an && an.fftSize !== size) { + an.fftSize = size; + // Force buffer reallocation on the next draw + _spectrumData = null; + _spectrumEma = null; + } + } catch { + /* ignore */ + } + }; + applyFft(); + fftBtn.addEventListener('click', () => { + const idx = fftKeys.indexOf(spectrumFftKey); + spectrumFftKey = fftKeys[(idx + 1) % fftKeys.length]; + try { + localStorage.setItem('autoeq-spectrum-fft', spectrumFftKey); + } catch { + /* ignore */ + } + applyFft(); + }); + } + // ======================================== // Redraw graph when target/settings change // ======================================== diff --git a/styles.css b/styles.css index e8adb85c..df557d4c 100644 --- a/styles.css +++ b/styles.css @@ -164,6 +164,7 @@ --ring: #f5f5f5; --highlight: #f5f5f5; --highlight-rgb: 245, 245, 245; + --highlight-foreground: #0a0a0a; --active-highlight: var(--highlight); --explicit-badge: #f5f5f5; } @@ -186,6 +187,7 @@ --ring: #3b82f6; --highlight: #3b82f6; --highlight-rgb: 59, 130, 246; + --highlight-foreground: #fff; --active-highlight: #3b82f6; --explicit-badge: #750a0a; } @@ -208,6 +210,7 @@ --ring: #06b6d4; --highlight: #06b6d4; --highlight-rgb: 6, 182, 212; + --highlight-foreground: #0c1821; --active-highlight: #06b6d4; --explicit-badge: #f43f5e; } @@ -230,6 +233,7 @@ --ring: #a855f7; --highlight: #a855f7; --highlight-rgb: 168, 85, 247; + --highlight-foreground: #fff; --active-highlight: #a855f7; --explicit-badge: #ec4899; } @@ -252,6 +256,7 @@ --ring: #22c55e; --highlight: #22c55e; --highlight-rgb: 34, 197, 94; + --highlight-foreground: #0a1409; --active-highlight: #22c55e; --explicit-badge: #f59e0b; } @@ -274,6 +279,7 @@ --ring: #89b4fa; --highlight: #89b4fa; --highlight-rgb: 180, 190, 254; + --highlight-foreground: #1e1e2e; --active-highlight: #b4befe; --explicit-badge: #f9e2af; } @@ -296,6 +302,7 @@ --ring: #8aadf4; --highlight: #8aadf4; --highlight-rgb: 183, 189, 248; + --highlight-foreground: #24273a; --active-highlight: #b7bdf8; --explicit-badge: #eed49f; } @@ -318,6 +325,7 @@ --ring: #8caaee; --highlight: #8caaee; --highlight-rgb: 186, 187, 241; + --highlight-foreground: #303446; --active-highlight: #babbf1; --explicit-badge: #e5c890; } @@ -340,6 +348,7 @@ --ring: #fdfdfd; --highlight: #1e66f5; --highlight-rgb: 114, 135, 253; + --highlight-foreground: #eff1f5; --active-highlight: #7287fd; --explicit-badge: #df8e1d; } @@ -362,6 +371,7 @@ --ring: #1a1a1a; --highlight: #1a1a1a; --highlight-rgb: 26, 26, 26; + --highlight-foreground: #f5f5f5; --active-highlight: var(--highlight); --explicit-badge: #1a1a1a; --cover-filter: blur(50px) brightness(1.6) opacity(0.35); @@ -8339,6 +8349,98 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { background: var(--highlight); } +.eq-spectrum-controls { + grid-row: 1; + grid-column: 3; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + flex-wrap: wrap; + justify-content: flex-end; + z-index: 2; +} + +.eq-spectrum-range { + grid-row: 1; + grid-column: 1; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + z-index: 2; +} + +.eq-spectrum-knob { + cursor: ns-resize; + touch-action: none; +} + +.eq-spectrum-pill, +.eq-spectrum-toggle-in-graph { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 6px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 999px; + background: rgb(0 0 0 / 45%); + color: rgb(255 255 255 / 70%); + cursor: pointer; + user-select: none; + backdrop-filter: blur(4px); + transition: + color var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast); +} + +.eq-spectrum-pill .pill-label { + opacity: 0.55; + font-weight: 500; +} + +.eq-spectrum-pill .pill-value { + color: #fff; + font-variant-numeric: tabular-nums; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.eq-spectrum-toggle-in-graph svg { + flex-shrink: 0; +} + +.eq-spectrum-pill:hover, +.eq-spectrum-toggle-in-graph:hover { + color: #fff; + border-color: rgb(255 255 255 / 35%); + background: rgb(0 0 0 / 60%); +} + +.eq-spectrum-toggle-in-graph.active, +.eq-spectrum-pill.active { + color: var(--highlight-foreground, var(--primary-foreground)); + background: var(--highlight); + border-color: var(--highlight); + box-shadow: 0 0 0 1px rgb(var(--highlight-rgb), 0.3); +} + +.eq-spectrum-pill.active .pill-value, +.eq-spectrum-pill.active .pill-label { + color: inherit; + opacity: 1; +} + +.eq-spectrum-knob.dragging { + color: var(--primary-foreground); + background: rgb(var(--highlight-rgb), 0.35); + border-color: rgb(var(--highlight-rgb), 0.6); +} + .eq-howto-panel { border: 1px solid var(--border); border-radius: var(--radius); @@ -8495,6 +8597,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { position: relative; width: 100%; height: 300px; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto 1fr; + column-gap: 8px; + align-items: start; background: color-mix(in srgb, var(--background) 25%, #111); border: 1px solid var(--border); border-radius: var(--radius); @@ -8503,8 +8610,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .autoeq-response-canvas { display: block; + grid-row: 2; + grid-column: 1 / -1; width: 100%; height: 100%; + min-height: 0; cursor: crosshair; } @@ -9607,9 +9717,51 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } } +@media (max-width: 600px) { + .autoeq-graph-wrapper { + display: flex; + flex-direction: column; + height: auto; + min-height: 260px; + overflow: visible; + } + + .autoeq-graph-wrapper .autoeq-response-canvas { + order: 3; + flex: 1 1 auto; + width: 100%; + height: auto; + min-height: 180px; + } + + .eq-spectrum-range { + order: 1; + padding: 6px 8px 0; + align-self: flex-start; + } + + .eq-spectrum-controls { + order: 2; + flex-wrap: wrap; + justify-content: flex-end; + padding: 4px 8px 6px; + width: 100%; + } + + .eq-spectrum-pill, + .eq-spectrum-toggle-in-graph { + padding: 4px 8px; + font-size: 0.68rem; + } +} + @media (max-width: 480px) { .autoeq-graph-wrapper { - height: 180px; + min-height: 240px; + } + + .autoeq-graph-wrapper .autoeq-response-canvas { + min-height: 160px; } .autoeq-graph-header {