From d4d1fe8494b113c8d7d1217e4264c6efc8d63c11 Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 1 Apr 2026 16:49:42 -0400 Subject: [PATCH] feat: AutoEQ and speaker EQ enhancements Adds AutoEQ integration with interactive parametric EQ graph, speaker/room correction with shelf filters, and improved EQ persistence via IndexedDB. --- .github/workflows/lint.yml | 1 + index.html | 978 +++++--- js/api.js | 8 +- js/audio-context.js | 221 +- js/autoeq-data.js | 4396 ++++++++++++++++++++++++++++++++++++ js/autoeq-engine.js | 221 ++ js/autoeq-importer.js | 219 ++ js/equalizer.js | 26 +- js/player.js | 30 +- js/settings.js | 4122 +++++++++++++++++++++++++-------- js/storage.js | 228 +- js/ui.js | 52 - styles.css | 1693 +++++++++----- 13 files changed, 10305 insertions(+), 1890 deletions(-) create mode 100644 js/autoeq-data.js create mode 100644 js/autoeq-engine.js create mode 100644 js/autoeq-importer.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf36fb4..f177d13 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,6 +19,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 1 + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.head_ref || github.ref }} - name: Setup Bun diff --git a/index.html b/index.html index 6deece3..4b8e51a 100644 --- a/index.html +++ b/index.html @@ -3871,32 +3871,6 @@ -
-
- Fullscreen Cover Tilt - 3D tilt effect on album cover in fullscreen view -
- -
-
-
- Preload Next Track - Seconds before track ends to start loading next -
- -
@@ -3995,9 +3969,9 @@
- Equalizer + AutoEQ 16-band parametric equalizer for fine audio controlPrecision headphone correction & parametric equalizer
-
-
- No content blocked yet. -
- - - - - - -

{ const filter = this.audioContext.createBiquadFilter(); - filter.type = 'peaking'; + filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking'; filter.frequency.value = freq; - filter.Q.value = this._calculateQ(index); + filter.Q.value = + this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); filter.gain.value = this.currentGains[index] || 0; return filter; }); @@ -312,10 +313,10 @@ class AudioContextManager { try { this.audioContext = new AudioContext(highResOptions); console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`); - } catch (e) { + } catch { try { this.audioContext = new AudioContext({ latencyHint: 'playback' }); - } catch (e2) { + } catch { this.audioContext = new AudioContext(); } } @@ -358,7 +359,9 @@ class AudioContextManager { if (this.source) { try { this.source.disconnect(); - } catch (e) {} + } catch { + // node may already be disconnected + } } this.audio = audioElement; @@ -386,7 +389,9 @@ class AudioContextManager { // Disconnect everything first try { this.source.disconnect(); - } catch (e) {} + } catch { + // node may already be disconnected + } this.outputNode.disconnect(); if (this.volumeNode) { this.volumeNode.disconnect(); @@ -405,16 +410,23 @@ class AudioContextManager { // Apply mono audio if enabled if (this.isMonoAudioEnabled && this.monoMergerNode) { - // Create a gain node to mix channels before the merger - const monoGain = this.audioContext.createGain(); - monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing + // Reuse persistent gain node to avoid leaking AudioNodes + if (!this.monoGainNode) { + this.monoGainNode = this.audioContext.createGain(); + this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing + } + try { + this.monoGainNode.disconnect(); + } catch { + /* not connected */ + } // Connect source to mono gain - this.source.connect(monoGain); + this.source.connect(this.monoGainNode); // Connect mono gain to both inputs of the merger - monoGain.connect(this.monoMergerNode, 0, 0); - monoGain.connect(this.monoMergerNode, 0, 1); + this.monoGainNode.connect(this.monoMergerNode, 0, 0); + this.monoGainNode.connect(this.monoMergerNode, 0, 1); lastNode = this.monoMergerNode; console.log('[AudioContext] Mono audio enabled'); @@ -573,6 +585,57 @@ class AudioContextManager { return equalizerSettings.getRange(); } + /** + * Calculate biquad filter magnitude response in dB at a given frequency + */ + _biquadResponseDb(f, band, sr) { + if (!band.enabled || !band.type) return 0; + const w = (2 * Math.PI * band.freq) / sr; + const p = (2 * Math.PI * f) / sr; + const s = Math.sin(w) / (2 * band.q); + const A = Math.pow(10, band.gain / 40); + const c = Math.cos(w); + let b0, b1, b2, a0, a1, a2; + const t = band.type[0]; + if (t === 'p') { + b0 = 1 + s * A; + b1 = -2 * c; + b2 = 1 - s * A; + a0 = 1 + s / A; + a1 = -2 * c; + a2 = 1 - s / A; + } else if (t === 'l') { + const sq = 2 * Math.sqrt(A) * s; + b0 = A * (A + 1 - (A - 1) * c + sq); + b1 = 2 * A * (A - 1 - (A + 1) * c); + b2 = A * (A + 1 - (A - 1) * c - sq); + a0 = A + 1 + (A - 1) * c + sq; + a1 = -2 * (A - 1 + (A + 1) * c); + a2 = A + 1 + (A - 1) * c - sq; + } else if (t === 'h') { + const sq = 2 * Math.sqrt(A) * s; + b0 = A * (A + 1 + (A - 1) * c + sq); + b1 = -2 * A * (A - 1 + (A + 1) * c); + b2 = A * (A + 1 + (A - 1) * c - sq); + a0 = A + 1 - (A - 1) * c + sq; + a1 = 2 * (A - 1 - (A + 1) * c); + a2 = A + 1 - (A - 1) * c - sq; + } else { + return 0; + } + const _a0 = 1 / a0; + const b0n = b0 * _a0, + b1n = b1 * _a0, + b2n = b2 * _a0; + const a1n = a1 * _a0, + a2n = a2 * _a0; + const cp = Math.cos(p), + c2p = Math.cos(2 * p); + const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p; + const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p; + return 10 * Math.log10(n / d); + } + /** * Clamp gain to valid range */ @@ -667,6 +730,8 @@ class AudioContextManager { this.freqRange = equalizerSettings.getFreqRange(); this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max); this.currentGains = equalizerSettings.getGains(this.bandCount); + this.currentTypes = equalizerSettings.getBandTypes(this.bandCount); + this.currentQs = equalizerSettings.getBandQs(this.bandCount); this.isMonoAudioEnabled = monoAudioSettings.isEnabled(); this.preamp = equalizerSettings.getPreamp(); } @@ -697,7 +762,88 @@ class AudioContextManager { } /** - * Called when the app enters the background (screen lock, app switch). + * Apply AutoEQ-generated bands to the equalizer + * Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values + * @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands + * @returns {string} Exported text representation of the applied EQ + */ + applyAutoEQBands(bands, skipPreamp = false) { + if (!bands || bands.length === 0) return ''; + + const enabledBands = bands.filter((b) => b.enabled); + const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length)); + + // Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping + let cumulativePeak = 0; + if (!skipPreamp) { + const sr = this.audioContext?.sampleRate ?? 48000; + // Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks + for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) { + let sum = 0; + for (const b of enabledBands) { + sum += this._biquadResponseDb(f, b, sr); + } + if (sum > cumulativePeak) cumulativePeak = sum; + } + } + const preamp = skipPreamp + ? equalizerSettings.getPreamp() + : cumulativePeak > 0 + ? -Math.round(cumulativePeak * 10) / 10 + : 0; + + // Sort bands by frequency so index order is deterministic + const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq); + + // Build normalized band descriptor arrays + const newFrequencies = sortedBands + .slice(0, count) + .map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1))); + const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking'); + const newQs = sortedBands.slice(0, count).map((b) => b.q); + const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain)); + + // Update band count via class setter to trigger equalizer-band-count-changed event + if (count !== this.bandCount) { + this.setBandCount(count); + } + + // Override frequencies, types, and Qs with band-specific values + this.frequencies = newFrequencies; + this.currentTypes = newTypes; + this.currentQs = newQs; + this.currentGains = newGains; + + // Rebuild EQ so _createEQ picks up the new types/Qs + if (this.isInitialized && this.audioContext) { + this._destroyEQ(); + this._createEQ(); + this._connectGraph(); + } + + // Apply preamp (skip if caller manages preamp externally) + if (!skipPreamp) { + this.setPreamp(preamp); + } + + // Persist normalized band descriptors to settings store + equalizerSettings.setGains(this.currentGains); + equalizerSettings.setBandTypes(this.currentTypes); + equalizerSettings.setBandQs(this.currentQs); + + // Generate export text using clamped gain values + const lines = [`Preamp: ${preamp.toFixed(1)} dB`]; + sortedBands.forEach((band, index) => { + if (index >= count) return; + const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK'; + lines.push( + `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}` + ); + }); + + return lines.join('\n'); + } + /** * Export equalizer settings to text format * @returns {string} Exported settings in text format @@ -709,8 +855,13 @@ class AudioContextManager { this.frequencies.forEach((freq, index) => { const gain = this.currentGains[index] || 0; + const type = (this.currentTypes && this.currentTypes[index]) || 'peaking'; + const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK'; + const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); const filterNum = index + 1; - lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`); + lines.push( + `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}` + ); }); return lines.join('\n'); @@ -760,24 +911,42 @@ class AudioContextManager { this.setPreamp(preamp); // If different number of bands, adjust - if (filters.length !== this.bandCount) { - const newCount = Math.max( - equalizerSettings.MIN_BANDS, - Math.min(equalizerSettings.MAX_BANDS, filters.length) - ); + const newCount = Math.max( + equalizerSettings.MIN_BANDS, + Math.min(equalizerSettings.MAX_BANDS, filters.length) + ); + if (newCount !== this.bandCount) { this.setBandCount(newCount); } - // Extract gains from filters - const gains = filters.slice(0, this.bandCount).map((f) => f.gain); - this.setAllGains(gains); + // Apply per-band frequencies, types, Qs, and gains from import + const sliced = filters.slice(0, this.bandCount); + const typeMap = { + PK: 'peaking', + LS: 'lowshelf', + LSC: 'lowshelf', + LSF: 'lowshelf', + HS: 'highshelf', + HSC: 'highshelf', + HSF: 'highshelf', + }; + this.frequencies = sliced.map((f) => f.freq); + this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking'); + this.currentQs = sliced.map((f) => f.q); + this.currentGains = sliced.map((f) => this._clampGain(f.gain)); - // Store filter frequencies if different - const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); - if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { - equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); + // Rebuild EQ chain to apply new frequencies, types, and Qs + if (this.isInitialized && this.audioContext) { + this._destroyEQ(); + this._createEQ(); + this._connectGraph(); } + // Persist all band settings + equalizerSettings.setGains(this.currentGains); + equalizerSettings.setBandTypes(this.currentTypes); + equalizerSettings.setBandQs(this.currentQs); + return true; } catch (e) { console.warn('[AudioContext] Failed to import EQ settings:', e); diff --git a/js/autoeq-data.js b/js/autoeq-data.js new file mode 100644 index 0000000..784fbfb --- /dev/null +++ b/js/autoeq-data.js @@ -0,0 +1,4396 @@ +// js/autoeq-data.js +// Target Curves & Data Parser - Ported from Seap Engine +// Contains target frequency response curves and raw data parser + + +// Raw content synchronized with features/autoeq/Targets/ directory + +const RAW_HARMAN_OE_2018 = ` +20.00 78.964 +20.36 78.969 +20.73 78.975 +21.11 78.980 +21.50 78.985 +21.89 78.990 +22.29 78.994 +22.69 78.998 +23.10 79.001 +23.52 79.004 +23.95 79.005 +24.39 79.006 +24.83 79.006 +25.28 79.005 +25.74 79.003 +26.21 79.000 +26.69 78.996 +27.18 78.992 +27.67 78.986 +28.17 78.980 +28.69 78.972 +29.21 78.964 +29.74 78.955 +30.28 78.945 +30.83 78.934 +31.39 78.923 +31.97 78.911 +32.55 78.898 +33.14 78.884 +33.74 78.870 +34.36 78.854 +34.98 78.838 +35.62 78.822 +36.27 78.804 +36.93 78.786 +37.60 78.766 +38.28 78.746 +38.98 78.725 +39.69 78.703 +40.41 78.680 +41.15 78.655 +41.90 78.630 +42.66 78.603 +43.44 78.575 +44.23 78.546 +45.03 78.516 +45.85 78.485 +46.68 78.452 +47.53 78.418 +48.40 78.383 +49.28 78.346 +50.18 78.309 +51.09 78.270 +52.02 78.230 +52.97 78.189 +53.93 78.147 +54.91 78.104 +55.91 78.060 +56.93 78.015 +57.97 77.969 +59.02 77.923 +60.09 77.875 +61.19 77.826 +62.30 77.776 +63.44 77.725 +64.59 77.674 +65.77 77.621 +66.96 77.567 +68.18 77.513 +69.42 77.457 +70.69 77.400 +71.97 77.342 +73.28 77.283 +74.62 77.223 +75.97 77.161 +77.36 77.099 +78.76 77.034 +80.20 76.969 +81.66 76.901 +83.14 76.833 +84.66 76.762 +86.20 76.690 +87.77 76.616 +89.36 76.541 +90.99 76.464 +92.65 76.385 +94.33 76.304 +96.05 76.222 +97.80 76.138 +99.58 76.053 +101.39 75.966 +103.23 75.878 +105.11 75.790 +107.03 75.700 +108.97 75.609 +110.96 75.518 +112.98 75.427 +115.03 75.335 +117.13 75.244 +119.26 75.152 +121.43 75.062 +123.64 74.972 +125.89 74.882 +128.18 74.794 +130.51 74.706 +132.89 74.620 +135.31 74.535 +137.77 74.452 +140.28 74.369 +142.83 74.288 +145.43 74.208 +148.07 74.130 +150.77 74.053 +153.51 73.977 +156.31 73.902 +159.15 73.828 +162.05 73.756 +165.00 73.685 +168.00 73.616 +171.06 73.549 +174.17 73.483 +177.34 73.420 +180.57 73.360 +183.86 73.302 +187.20 73.247 +190.61 73.196 +194.08 73.149 +197.61 73.105 +201.21 73.067 +204.87 73.032 +208.60 73.003 +212.39 72.979 +216.26 72.960 +220.19 72.946 +224.20 72.937 +228.28 72.934 +232.44 72.935 +236.67 72.942 +240.97 72.952 +245.36 72.968 +249.82 72.987 +254.37 73.010 +259.00 73.036 +263.71 73.065 +268.51 73.097 +273.40 73.132 +278.38 73.168 +283.44 73.205 +288.60 73.244 +293.85 73.283 +299.20 73.323 +304.65 73.364 +310.19 73.404 +315.84 73.443 +321.59 73.482 +327.44 73.520 +333.40 73.558 +339.47 73.593 +345.64 73.628 +351.93 73.661 +358.34 73.692 +364.86 73.722 +371.50 73.751 +378.26 73.778 +385.15 73.805 +392.16 73.830 +399.29 73.854 +406.56 73.877 +413.96 73.900 +421.49 73.923 +429.16 73.946 +436.97 73.969 +444.93 73.993 +453.02 74.017 +461.27 74.041 +469.66 74.067 +478.21 74.093 +486.91 74.119 +495.78 74.147 +504.80 74.175 +513.99 74.204 +523.34 74.234 +532.87 74.263 +542.56 74.293 +552.44 74.324 +562.49 74.354 +572.73 74.384 +583.15 74.415 +593.77 74.444 +604.57 74.474 +615.57 74.503 +626.78 74.531 +638.18 74.559 +649.80 74.585 +661.63 74.611 +673.67 74.635 +685.93 74.658 +698.41 74.680 +711.12 74.701 +724.06 74.720 +737.24 74.738 +750.66 74.754 +764.32 74.769 +778.23 74.783 +792.39 74.796 +806.82 74.808 +821.50 74.819 +836.45 74.830 +851.67 74.841 +867.17 74.852 +882.96 74.863 +899.02 74.876 +915.39 74.891 +932.05 74.907 +949.01 74.926 +966.28 74.948 +983.87 74.974 +1001.77 75.003 +1020.00 75.038 +1038.57 75.077 +1057.47 75.122 +1076.71 75.172 +1096.31 75.229 +1116.26 75.291 +1136.58 75.360 +1157.26 75.435 +1178.32 75.517 +1199.77 75.604 +1221.60 75.698 +1243.84 75.797 +1266.47 75.901 +1289.52 76.011 +1312.99 76.125 +1336.89 76.244 +1361.22 76.367 +1385.99 76.495 +1411.22 76.626 +1436.90 76.763 +1463.05 76.904 +1489.68 77.050 +1516.79 77.202 +1544.40 77.360 +1572.50 77.524 +1601.12 77.695 +1630.26 77.873 +1659.93 78.057 +1690.14 78.249 +1720.90 78.446 +1752.22 78.649 +1784.11 78.858 +1816.58 79.070 +1849.64 79.285 +1883.30 79.501 +1917.58 79.718 +1952.48 79.935 +1988.01 80.149 +2024.19 80.360 +2061.03 80.567 +2098.54 80.770 +2136.73 80.967 +2175.62 81.158 +2215.22 81.343 +2255.53 81.521 +2296.58 81.692 +2338.38 81.855 +2380.94 82.011 +2424.27 82.159 +2468.39 82.298 +2513.31 82.430 +2559.05 82.554 +2605.63 82.670 +2653.05 82.778 +2701.33 82.879 +2750.50 82.974 +2800.55 83.061 +2851.52 83.142 +2903.42 83.217 +2956.26 83.285 +3010.06 83.346 +3064.85 83.401 +3120.62 83.449 +3177.42 83.490 +3235.25 83.522 +3294.13 83.546 +3354.08 83.560 +3415.12 83.565 +3477.27 83.560 +3540.56 83.545 +3605.00 83.519 +3670.60 83.483 +3737.41 83.437 +3805.43 83.379 +3874.68 83.312 +3945.20 83.234 +4017.00 83.146 +4090.11 83.048 +4164.55 82.940 +4240.34 82.822 +4317.51 82.696 +4396.09 82.562 +4476.10 82.421 +4557.56 82.274 +4640.50 82.122 +4724.96 81.968 +4810.95 81.812 +4898.51 81.656 +4987.66 81.501 +5078.43 81.349 +5170.86 81.200 +5264.97 81.055 +5360.79 80.914 +5458.35 80.776 +5557.69 80.641 +5658.84 80.616 +5761.82 80.598 +5866.69 80.583 +5973.46 80.574 +6082.17 80.572 +6192.87 80.576 +6305.57 80.584 +6420.33 80.595 +6537.18 80.605 +6656.15 80.615 +6777.29 80.626 +6900.63 80.637 +7026.22 80.652 +7154.10 80.667 +7284.30 80.685 +7416.87 80.702 +7551.85 80.714 +7689.29 80.716 +7829.23 80.696 +7971.72 80.644 +8116.80 80.555 +8264.53 80.432 +8414.94 80.285 +8568.09 80.118 +8724.02 79.943 +8882.79 79.766 +9044.46 79.593 +9209.06 79.424 +9376.66 79.258 +9547.31 79.094 +9721.07 78.929 +9897.99 78.759 +10078.13 78.583 +10261.55 78.397 +10448.30 78.201 +10638.45 77.994 +10832.07 77.777 +11029.21 77.556 +11229.94 77.331 +11434.32 77.102 +11642.41 76.871 +11854.30 76.637 +12070.04 76.400 +12289.71 76.157 +12513.38 75.908 +12741.12 75.657 +12973.00 75.407 +13209.10 75.162 +13449.50 74.922 +13694.28 74.687 +13943.51 74.454 +14197.27 74.223 +14455.66 73.992 +14718.74 73.763 +14986.62 73.531 +15259.37 73.299 +15537.08 73.067 +15819.85 72.833 +16107.76 72.601 +16400.92 72.367 +16699.41 72.134 +17003.33 71.901 +17312.78 71.668 +17627.86 71.435 +17948.68 71.200 +18275.34 70.963 +18607.94 70.722 +18946.60 70.474 +19291.42 70.219 +19642.52 69.959 +20000.00 69.695 +`; + +const RAW_HARMAN_IE_2019 = ` +20.00 82.649 +20.36 82.656 +20.73 82.664 +21.11 82.671 +21.50 82.678 +21.89 82.685 +22.29 82.692 +22.69 82.699 +23.10 82.705 +23.52 82.711 +23.95 82.717 +24.39 82.722 +24.83 82.727 +25.28 82.731 +25.74 82.734 +26.21 82.737 +26.69 82.739 +27.18 82.741 +27.67 82.741 +28.17 82.741 +28.69 82.739 +29.21 82.737 +29.74 82.733 +30.28 82.728 +30.83 82.722 +31.39 82.714 +31.97 82.705 +32.55 82.694 +33.14 82.681 +33.74 82.666 +34.36 82.649 +34.98 82.630 +35.62 82.608 +36.27 82.584 +36.93 82.557 +37.60 82.528 +38.28 82.496 +38.98 82.461 +39.69 82.423 +40.41 82.382 +41.15 82.338 +41.90 82.291 +42.66 82.242 +43.44 82.189 +44.23 82.133 +45.03 82.074 +45.85 82.013 +46.68 81.949 +47.53 81.881 +48.40 81.812 +49.28 81.740 +50.18 81.665 +51.09 81.588 +52.02 81.510 +52.97 81.429 +53.93 81.346 +54.91 81.261 +55.91 81.174 +56.93 81.086 +57.97 80.995 +59.02 80.904 +60.09 80.811 +61.19 80.716 +62.30 80.620 +63.44 80.523 +64.59 80.425 +65.77 80.326 +66.96 80.225 +68.18 80.124 +69.42 80.022 +70.69 79.919 +71.97 79.816 +73.28 79.712 +74.62 79.607 +75.97 79.502 +77.36 79.396 +78.76 79.290 +80.20 79.183 +81.66 79.076 +83.14 78.968 +84.66 78.860 +86.20 78.751 +87.77 78.641 +89.36 78.531 +90.99 78.420 +92.65 78.309 +94.33 78.197 +96.05 78.085 +97.80 77.972 +99.58 77.859 +101.39 77.746 +103.23 77.633 +105.11 77.519 +107.03 77.406 +108.97 77.293 +110.96 77.180 +112.98 77.068 +115.03 76.956 +117.13 76.844 +119.26 76.733 +121.43 76.624 +123.64 76.515 +125.89 76.407 +128.18 76.300 +130.51 76.195 +132.89 76.091 +135.31 75.988 +137.77 75.887 +140.28 75.787 +142.83 75.688 +145.43 75.590 +148.07 75.494 +150.77 75.399 +153.51 75.304 +156.31 75.211 +159.15 75.119 +162.05 75.027 +165.00 74.937 +168.00 74.847 +171.06 74.759 +174.17 74.671 +177.34 74.585 +180.57 74.501 +183.86 74.418 +187.20 74.336 +190.61 74.257 +194.08 74.181 +197.61 74.106 +201.21 74.035 +204.87 73.966 +208.60 73.901 +212.39 73.838 +216.26 73.779 +220.19 73.724 +224.20 73.672 +228.28 73.623 +232.44 73.578 +236.67 73.537 +240.97 73.499 +245.36 73.465 +249.82 73.435 +254.37 73.407 +259.00 73.383 +263.71 73.362 +268.51 73.344 +273.40 73.329 +278.38 73.317 +283.44 73.307 +288.60 73.299 +293.85 73.293 +299.20 73.290 +304.65 73.288 +310.19 73.289 +315.84 73.290 +321.59 73.293 +327.44 73.297 +333.40 73.303 +339.47 73.309 +345.64 73.317 +351.93 73.325 +358.34 73.334 +364.86 73.344 +371.50 73.354 +378.26 73.365 +385.15 73.377 +392.16 73.389 +399.29 73.401 +406.56 73.414 +413.96 73.428 +421.49 73.442 +429.16 73.456 +436.97 73.472 +444.93 73.487 +453.02 73.504 +461.27 73.520 +469.66 73.538 +478.21 73.556 +486.91 73.574 +495.78 73.593 +504.80 73.613 +513.99 73.633 +523.34 73.653 +532.87 73.675 +542.56 73.696 +552.44 73.719 +562.49 73.742 +572.73 73.766 +583.15 73.791 +593.77 73.817 +604.57 73.843 +615.57 73.871 +626.78 73.899 +638.18 73.928 +649.80 73.959 +661.63 73.990 +673.67 74.022 +685.93 74.054 +698.41 74.088 +711.12 74.122 +724.06 74.157 +737.24 74.193 +750.66 74.229 +764.32 74.266 +778.23 74.305 +792.39 74.344 +806.82 74.384 +821.50 74.425 +836.45 74.468 +851.67 74.512 +867.17 74.558 +882.96 74.606 +899.02 74.655 +915.39 74.707 +932.05 74.762 +949.01 74.819 +966.28 74.878 +983.87 74.941 +1001.77 75.007 +1020.00 75.076 +1038.57 75.149 +1057.47 75.226 +1076.71 75.308 +1096.31 75.394 +1116.26 75.484 +1136.58 75.579 +1157.26 75.679 +1178.32 75.784 +1199.77 75.894 +1221.60 76.008 +1243.84 76.126 +1266.47 76.249 +1289.52 76.375 +1312.99 76.505 +1336.89 76.638 +1361.22 76.773 +1385.99 76.912 +1411.22 77.054 +1436.90 77.198 +1463.05 77.346 +1489.68 77.499 +1516.79 77.655 +1544.40 77.817 +1572.50 77.984 +1601.12 78.157 +1630.26 78.337 +1659.93 78.524 +1690.14 78.717 +1720.90 78.918 +1752.22 79.125 +1784.11 79.338 +1816.58 79.556 +1849.64 79.779 +1883.30 80.006 +1917.58 80.235 +1952.48 80.466 +1988.01 80.697 +2024.19 80.928 +2061.03 81.157 +2098.54 81.384 +2136.73 81.608 +2175.62 81.828 +2215.22 82.042 +2255.53 82.252 +2296.58 82.454 +2338.38 82.650 +2380.94 82.837 +2424.27 83.016 +2468.39 83.185 +2513.31 83.344 +2559.05 83.492 +2605.63 83.630 +2653.05 83.757 +2701.33 83.872 +2750.50 83.976 +2800.55 84.069 +2851.52 84.150 +2903.42 84.220 +2956.26 84.280 +3010.06 84.328 +3064.85 84.366 +3120.62 84.393 +3177.42 84.410 +3235.25 84.417 +3294.13 84.414 +3354.08 84.402 +3415.12 84.380 +3477.27 84.350 +3540.56 84.312 +3605.00 84.266 +3670.60 84.214 +3737.41 84.156 +3805.43 84.093 +3874.68 84.025 +3945.20 83.952 +4017.00 83.875 +4090.11 83.795 +4164.55 83.711 +4240.34 83.624 +4317.51 83.534 +4396.09 83.442 +4476.10 83.347 +4557.56 83.251 +4640.50 83.154 +4724.96 83.056 +4810.95 82.958 +4898.51 82.860 +4987.66 82.763 +5078.43 82.667 +5170.86 82.572 +5264.97 82.477 +5360.79 82.383 +5458.35 82.288 +5557.69 82.192 +5658.84 82.093 +5761.82 81.991 +5866.69 81.884 +5973.46 81.771 +6082.17 81.651 +6192.87 81.523 +6305.57 81.386 +6420.33 81.239 +6537.18 81.083 +6656.15 80.915 +6777.29 80.737 +6900.63 80.547 +7026.22 80.345 +7154.10 80.132 +7284.30 79.908 +7416.87 79.674 +7551.85 79.429 +7689.29 79.174 +7829.23 78.910 +7971.72 78.634 +8116.80 78.348 +8264.53 78.050 +8414.94 77.740 +8568.09 77.418 +8724.02 77.084 +8882.79 76.740 +9044.46 76.385 +9209.06 76.021 +9376.66 75.649 +9547.31 75.272 +9721.07 74.891 +9897.99 74.507 +10078.13 74.123 +10261.55 73.739 +10448.30 73.355 +10638.45 72.971 +10832.07 72.588 +11029.21 72.205 +11229.94 71.823 +11434.32 71.442 +11642.41 71.063 +11854.30 70.687 +12070.04 70.315 +12289.71 69.946 +12513.38 69.583 +12741.12 69.225 +12973.00 68.875 +13209.10 68.535 +13449.50 68.206 +13694.28 67.894 +13943.51 67.600 +14197.27 67.328 +14455.66 67.083 +14718.74 66.865 +14986.62 66.675 +15259.37 66.509 +15537.08 66.358 +15819.85 66.205 +16107.76 66.020 +16400.92 65.757 +16699.41 65.348 +17003.33 64.704 +17312.78 63.707 +17627.86 62.214 +17948.68 60.060 +18275.34 57.070 +18607.94 53.077 +18946.60 47.955 +19291.42 41.677 +19642.52 34.385 +20000.00 26.435 +`; + +const RAW_DIFFUSE_FIELD = ` +20.00 71.058 +20.36 71.058 +20.73 71.058 +21.11 71.058 +21.50 71.058 +21.89 71.058 +22.29 71.058 +22.69 71.058 +23.10 71.058 +23.52 71.058 +23.95 71.058 +24.39 71.058 +24.83 71.058 +25.28 71.058 +25.74 71.058 +26.21 71.058 +26.69 71.058 +27.18 71.058 +27.67 71.058 +28.17 71.058 +28.69 71.058 +29.21 71.058 +29.74 71.058 +30.28 71.058 +30.83 71.058 +31.39 71.058 +31.97 71.058 +32.55 71.058 +33.14 71.058 +33.74 71.058 +34.36 71.058 +34.98 71.058 +35.62 71.058 +36.27 71.058 +36.93 71.058 +37.60 71.058 +38.28 71.058 +38.98 71.058 +39.69 71.058 +40.41 71.058 +41.15 71.058 +41.90 71.058 +42.66 71.058 +43.44 71.058 +44.23 71.058 +45.03 71.058 +45.85 71.058 +46.68 71.058 +47.53 71.058 +48.40 71.058 +49.28 71.058 +50.18 71.058 +51.09 71.058 +52.02 71.058 +52.97 71.058 +53.93 71.058 +54.91 71.058 +55.91 71.058 +56.93 71.058 +57.97 71.058 +59.02 71.058 +60.09 71.058 +61.19 71.058 +62.30 71.058 +63.44 71.058 +64.59 71.058 +65.77 71.058 +66.96 71.058 +68.18 71.058 +69.42 71.058 +70.69 71.058 +71.97 71.058 +73.28 71.058 +74.62 71.058 +75.97 71.058 +77.36 71.058 +78.76 71.058 +80.20 71.058 +81.66 71.058 +83.14 71.058 +84.66 71.057 +86.20 71.057 +87.77 71.057 +89.36 71.056 +90.99 71.056 +92.65 71.055 +94.33 71.055 +96.05 71.055 +97.80 71.056 +99.58 71.058 +101.39 71.061 +103.23 71.066 +105.11 71.072 +107.03 71.081 +108.97 71.091 +110.96 71.102 +112.98 71.113 +115.03 71.124 +117.13 71.134 +119.26 71.142 +121.43 71.149 +123.64 71.155 +125.89 71.161 +128.18 71.167 +130.51 71.174 +132.89 71.182 +135.31 71.192 +137.77 71.204 +140.28 71.216 +142.83 71.229 +145.43 71.241 +148.07 71.254 +150.77 71.267 +153.51 71.281 +156.31 71.297 +159.15 71.313 +162.05 71.331 +165.00 71.349 +168.00 71.368 +171.06 71.388 +174.17 71.408 +177.34 71.429 +180.57 71.449 +183.86 71.469 +187.20 71.489 +190.61 71.509 +194.08 71.529 +197.61 71.549 +201.21 71.568 +204.87 71.587 +208.60 71.607 +212.39 71.627 +216.26 71.646 +220.19 71.665 +224.20 71.684 +228.28 71.704 +232.44 71.724 +236.67 71.745 +240.97 71.765 +245.36 71.786 +249.82 71.808 +254.37 71.831 +259.00 71.854 +263.71 71.877 +268.51 71.900 +273.40 71.924 +278.38 71.947 +283.44 71.969 +288.60 71.991 +293.85 72.011 +299.20 72.031 +304.65 72.049 +310.19 72.065 +315.84 72.081 +321.59 72.096 +327.44 72.110 +333.40 72.123 +339.47 72.135 +345.64 72.147 +351.93 72.158 +358.34 72.170 +364.86 72.184 +371.50 72.200 +378.26 72.219 +385.15 72.242 +392.16 72.269 +399.29 72.301 +406.56 72.340 +413.96 72.385 +421.49 72.436 +429.16 72.493 +436.97 72.554 +444.93 72.619 +453.02 72.684 +461.27 72.750 +469.66 72.815 +478.21 72.879 +486.91 72.942 +495.78 73.002 +504.80 73.060 +513.99 73.115 +523.34 73.169 +532.87 73.221 +542.56 73.273 +552.44 73.325 +562.49 73.377 +572.73 73.430 +583.15 73.483 +593.77 73.537 +604.57 73.592 +615.57 73.646 +626.78 73.701 +638.18 73.756 +649.80 73.812 +661.63 73.868 +673.67 73.925 +685.93 73.982 +698.41 74.039 +711.12 74.095 +724.06 74.151 +737.24 74.205 +750.66 74.258 +764.32 74.309 +778.23 74.358 +792.39 74.404 +806.82 74.446 +821.50 74.486 +836.45 74.524 +851.67 74.561 +867.17 74.597 +882.96 74.635 +899.02 74.674 +915.39 74.717 +932.05 74.764 +949.01 74.815 +966.28 74.873 +983.87 74.937 +1001.77 75.008 +1020.00 75.088 +1038.57 75.175 +1057.47 75.271 +1076.71 75.373 +1096.31 75.482 +1116.26 75.595 +1136.58 75.714 +1157.26 75.836 +1178.32 75.962 +1199.77 76.092 +1221.60 76.226 +1243.84 76.364 +1266.47 76.505 +1289.52 76.649 +1312.99 76.795 +1336.89 76.942 +1361.22 77.092 +1385.99 77.242 +1411.22 77.396 +1436.90 77.553 +1463.05 77.714 +1489.68 77.882 +1516.79 78.058 +1544.40 78.243 +1572.50 78.440 +1601.12 78.650 +1630.26 78.873 +1659.93 79.109 +1690.14 79.357 +1720.90 79.617 +1752.22 79.886 +1784.11 80.162 +1816.58 80.445 +1849.64 80.734 +1883.30 81.025 +1917.58 81.320 +1952.48 81.618 +1988.01 81.918 +2024.19 82.221 +2061.03 82.528 +2098.54 82.840 +2136.73 83.158 +2175.62 83.482 +2215.22 83.811 +2255.53 84.142 +2296.58 84.472 +2338.38 84.794 +2380.94 85.105 +2424.27 85.398 +2468.39 85.667 +2513.31 85.910 +2559.05 86.121 +2605.63 86.298 +2653.05 86.442 +2701.33 86.552 +2750.50 86.631 +2800.55 86.683 +2851.52 86.710 +2903.42 86.713 +2956.26 86.695 +3010.06 86.656 +3064.85 86.596 +3120.62 86.515 +3177.42 86.415 +3235.25 86.294 +3294.13 86.154 +3354.08 85.998 +3415.12 85.828 +3477.27 85.648 +3540.56 85.458 +3605.00 85.262 +3670.60 85.060 +3737.41 84.854 +3805.43 84.645 +3874.68 84.434 +3945.20 84.222 +4017.00 84.008 +4090.11 83.795 +4164.55 83.582 +4240.34 83.370 +4317.51 83.160 +4396.09 82.954 +4476.10 82.754 +4557.56 82.560 +4640.50 82.376 +4724.96 82.201 +4810.95 82.037 +4898.51 81.886 +4987.66 81.746 +5078.43 81.619 +5170.86 81.503 +5264.97 81.397 +5360.79 81.298 +5458.35 81.206 +5557.69 81.119 +5658.84 81.037 +5761.82 80.961 +5866.69 80.891 +5973.46 80.826 +6082.17 80.768 +6192.87 80.718 +6305.57 80.674 +6420.33 80.636 +6537.18 80.604 +6656.15 80.576 +6777.29 80.551 +6900.63 80.530 +7026.22 80.510 +7154.10 80.489 +7284.30 80.466 +7416.87 80.436 +7551.85 80.397 +7689.29 80.345 +7829.23 80.277 +7971.72 80.190 +8116.80 80.082 +8264.53 79.953 +8414.94 79.806 +8568.09 79.643 +8724.02 79.470 +8882.79 79.288 +9044.46 79.102 +9209.06 78.915 +9376.66 78.726 +9547.31 78.535 +9721.07 78.343 +9897.99 78.148 +10078.13 77.949 +10261.55 77.745 +10448.30 77.536 +10638.45 77.321 +10832.07 77.100 +11029.21 76.874 +11229.94 76.642 +11434.32 76.407 +11642.41 76.167 +11854.30 75.924 +12070.04 75.678 +12289.71 75.430 +12513.38 75.179 +12741.12 74.927 +12973.00 74.675 +13209.10 74.425 +13449.50 74.178 +13694.28 73.933 +13943.51 73.692 +14197.27 73.452 +14455.66 73.215 +14718.74 72.978 +14986.62 72.742 +15259.37 72.507 +15537.08 72.272 +15819.85 72.037 +16107.76 71.801 +16400.92 71.566 +16699.41 71.329 +17003.33 71.092 +17312.78 70.854 +17627.86 70.614 +17948.68 70.373 +18275.34 70.130 +18607.94 69.885 +18946.60 69.639 +19291.42 69.390 +19642.52 69.141 +20000.00 68.891 +`; + +const RAW_KNOWLES = ` +20.00 82.754 +20.36 82.754 +20.73 82.755 +21.11 82.755 +21.50 82.755 +21.89 82.756 +22.29 82.756 +22.69 82.757 +23.10 82.758 +23.52 82.759 +23.95 82.760 +24.39 82.761 +24.83 82.762 +25.28 82.762 +25.74 82.763 +26.21 82.763 +26.69 82.763 +27.18 82.762 +27.67 82.761 +28.17 82.760 +28.69 82.758 +29.21 82.756 +29.74 82.753 +30.28 82.750 +30.83 82.746 +31.39 82.741 +31.97 82.736 +32.55 82.731 +33.14 82.725 +33.74 82.719 +34.36 82.712 +34.98 82.702 +35.62 82.689 +36.27 82.670 +36.93 82.647 +37.60 82.620 +38.28 82.591 +38.98 82.563 +39.69 82.533 +40.41 82.502 +41.15 82.469 +41.90 82.433 +42.66 82.394 +43.44 82.348 +44.23 82.294 +45.03 82.231 +45.85 82.160 +46.68 82.081 +47.53 81.996 +48.40 81.908 +49.28 81.819 +50.18 81.732 +51.09 81.649 +52.02 81.570 +52.97 81.493 +53.93 81.417 +54.91 81.341 +55.91 81.262 +56.93 81.180 +57.97 81.094 +59.02 81.004 +60.09 80.911 +61.19 80.817 +62.30 80.721 +63.44 80.623 +64.59 80.521 +65.77 80.414 +66.96 80.305 +68.18 80.196 +69.42 80.091 +70.69 79.989 +71.97 79.890 +73.28 79.791 +74.62 79.691 +75.97 79.591 +77.36 79.488 +78.76 79.383 +80.20 79.275 +81.66 79.165 +83.14 79.055 +84.66 78.946 +86.20 78.836 +87.77 78.727 +89.36 78.618 +90.99 78.508 +92.65 78.397 +94.33 78.286 +96.05 78.172 +97.80 78.058 +99.58 77.942 +101.39 77.825 +103.23 77.709 +105.11 77.592 +107.03 77.477 +108.97 77.364 +110.96 77.253 +112.98 77.142 +115.03 77.031 +117.13 76.917 +119.26 76.803 +121.43 76.691 +123.64 76.583 +125.89 76.483 +128.18 76.389 +130.51 76.297 +132.89 76.207 +135.31 76.116 +137.77 76.024 +140.28 75.929 +142.83 75.833 +145.43 75.734 +148.07 75.632 +150.77 75.528 +153.51 75.423 +156.31 75.319 +159.15 75.219 +162.05 75.123 +165.00 75.030 +168.00 74.941 +171.06 74.854 +174.17 74.769 +177.34 74.686 +180.57 74.603 +183.86 74.521 +187.20 74.438 +190.61 74.355 +194.08 74.271 +197.61 74.188 +201.21 74.107 +204.87 74.028 +208.60 73.951 +212.39 73.875 +216.26 73.801 +220.19 73.730 +224.20 73.667 +228.28 73.618 +232.44 73.581 +236.67 73.555 +240.97 73.537 +245.36 73.524 +249.82 73.514 +254.37 73.506 +259.00 73.496 +263.71 73.484 +268.51 73.471 +273.40 73.456 +278.38 73.440 +283.44 73.423 +288.60 73.407 +293.85 73.390 +299.20 73.374 +304.65 73.359 +310.19 73.345 +315.84 73.333 +321.59 73.324 +327.44 73.321 +333.40 73.324 +339.47 73.335 +345.64 73.355 +351.93 73.381 +358.34 73.409 +364.86 73.439 +371.50 73.466 +378.26 73.488 +385.15 73.504 +392.16 73.514 +399.29 73.521 +406.56 73.525 +413.96 73.527 +421.49 73.529 +429.16 73.533 +436.97 73.541 +444.93 73.554 +453.02 73.571 +461.27 73.590 +469.66 73.610 +478.21 73.628 +486.91 73.642 +495.78 73.652 +504.80 73.660 +513.99 73.666 +523.34 73.672 +532.87 73.680 +542.56 73.691 +552.44 73.705 +562.49 73.724 +572.73 73.748 +583.15 73.777 +593.77 73.809 +604.57 73.845 +615.57 73.883 +626.78 73.924 +638.18 73.964 +649.80 74.003 +661.63 74.037 +673.67 74.067 +685.93 74.095 +698.41 74.121 +711.12 74.147 +724.06 74.174 +737.24 74.202 +750.66 74.232 +764.32 74.264 +778.23 74.298 +792.39 74.332 +806.82 74.365 +821.50 74.400 +836.45 74.436 +851.67 74.474 +867.17 74.516 +882.96 74.563 +899.02 74.614 +915.39 74.671 +932.05 74.732 +949.01 74.797 +966.28 74.865 +983.87 74.935 +1001.77 75.007 +1020.00 75.079 +1038.57 75.152 +1057.47 75.227 +1076.71 75.307 +1096.31 75.393 +1116.26 75.487 +1136.58 75.586 +1157.26 75.689 +1178.32 75.797 +1199.77 75.908 +1221.60 76.022 +1243.84 76.139 +1266.47 76.260 +1289.52 76.385 +1312.99 76.514 +1336.89 76.649 +1361.22 76.790 +1385.99 76.935 +1411.22 77.087 +1436.90 77.244 +1463.05 77.402 +1489.68 77.559 +1516.79 77.712 +1544.40 77.863 +1572.50 78.014 +1601.12 78.167 +1630.26 78.324 +1659.93 78.490 +1690.14 78.666 +1720.90 78.856 +1752.22 79.059 +1784.11 79.276 +1816.58 79.505 +1849.64 79.744 +1883.30 79.988 +1917.58 80.236 +1952.48 80.483 +1988.01 80.726 +2024.19 80.967 +2061.03 81.205 +2098.54 81.441 +2136.73 81.675 +2175.62 81.908 +2215.22 82.138 +2255.53 82.364 +2296.58 82.580 +2338.38 82.780 +2380.94 82.966 +2424.27 83.141 +2468.39 83.310 +2513.31 83.473 +2559.05 83.631 +2605.63 83.779 +2653.05 83.915 +2701.33 84.038 +2750.50 84.149 +2800.55 84.247 +2851.52 84.334 +2903.42 84.410 +2956.26 84.475 +3010.06 84.532 +3064.85 84.581 +3120.62 84.619 +3177.42 84.645 +3235.25 84.660 +3294.13 84.662 +3354.08 84.654 +3415.12 84.637 +3477.27 84.610 +3540.56 84.575 +3605.00 84.529 +3670.60 84.473 +3737.41 84.410 +3805.43 84.342 +3874.68 84.274 +3945.20 84.207 +4017.00 84.140 +4090.11 84.072 +4164.55 84.001 +4240.34 83.927 +4317.51 83.852 +4396.09 83.775 +4476.10 83.697 +4557.56 83.618 +4640.50 83.536 +4724.96 83.450 +4810.95 83.361 +4898.51 83.271 +4987.66 83.182 +5078.43 83.097 +5170.86 83.015 +5264.97 82.937 +5360.79 82.864 +5458.35 82.795 +5557.69 82.731 +5658.84 82.670 +5761.82 82.610 +5866.69 82.548 +5973.46 82.478 +6082.17 82.401 +6192.87 82.315 +6305.57 82.223 +6420.33 82.122 +6537.18 82.011 +6656.15 81.889 +6777.29 81.754 +6900.63 81.607 +7026.22 81.450 +7154.10 81.285 +7284.30 81.115 +7416.87 80.939 +7551.85 80.758 +7689.29 80.573 +7829.23 80.381 +7971.72 80.181 +8116.80 79.970 +8264.53 79.744 +8414.94 79.504 +8568.09 79.251 +8724.02 78.986 +8882.79 78.715 +9044.46 78.444 +9209.06 78.175 +9376.66 77.913 +9547.31 77.658 +9721.07 77.413 +9897.99 77.181 +10078.13 76.963 +10261.55 76.760 +10448.30 76.567 +10638.45 76.381 +10832.07 76.202 +11029.21 76.032 +11229.94 75.870 +11434.32 75.722 +11642.41 75.594 +11854.30 75.493 +12070.04 75.424 +12289.71 75.391 +12513.38 75.397 +12741.12 75.443 +12973.00 75.528 +13209.10 75.650 +13449.50 75.804 +13694.28 75.985 +13943.51 76.181 +14197.27 76.380 +14455.66 76.567 +14718.74 76.723 +14986.62 76.824 +15259.37 76.846 +15537.08 76.765 +15819.85 76.557 +16107.76 76.196 +16400.92 75.664 +16699.41 74.955 +17003.33 74.069 +17312.78 73.022 +17627.86 71.842 +17948.68 70.566 +18275.34 69.231 +18607.94 67.864 +18946.60 66.480 +19291.42 65.085 +19642.52 63.676 +20000.00 62.254 +`; + +const RAW_MOONDROP_VDSF = ` +20.00 77.103 +20.36 77.093 +20.73 77.083 +21.11 77.073 +21.50 77.062 +21.89 77.051 +22.29 77.039 +22.69 77.026 +23.10 77.013 +23.52 76.999 +23.95 76.985 +24.39 76.971 +24.83 76.957 +25.28 76.943 +25.74 76.930 +26.21 76.917 +26.69 76.906 +27.18 76.895 +27.67 76.884 +28.17 76.875 +28.69 76.867 +29.21 76.859 +29.74 76.852 +30.28 76.845 +30.83 76.839 +31.39 76.834 +31.97 76.829 +32.55 76.824 +33.14 76.819 +33.74 76.815 +34.36 76.811 +34.98 76.807 +35.62 76.803 +36.27 76.799 +36.93 76.795 +37.60 76.791 +38.28 76.787 +38.98 76.783 +39.69 76.779 +40.41 76.774 +41.15 76.770 +41.90 76.765 +42.66 76.759 +43.44 76.753 +44.23 76.747 +45.03 76.740 +45.85 76.731 +46.68 76.722 +47.53 76.711 +48.40 76.698 +49.28 76.684 +50.18 76.667 +51.09 76.649 +52.02 76.628 +52.97 76.604 +53.93 76.579 +54.91 76.551 +55.91 76.521 +56.93 76.489 +57.97 76.455 +59.02 76.420 +60.09 76.385 +61.19 76.349 +62.30 76.312 +63.44 76.277 +64.59 76.242 +65.77 76.208 +66.96 76.176 +68.18 76.146 +69.42 76.118 +70.69 76.091 +71.97 76.067 +73.28 76.044 +74.62 76.023 +75.97 76.004 +77.36 75.986 +78.76 75.969 +80.20 75.952 +81.66 75.935 +83.14 75.918 +84.66 75.900 +86.20 75.881 +87.77 75.861 +89.36 75.839 +90.99 75.816 +92.65 75.790 +94.33 75.763 +96.05 75.733 +97.80 75.702 +99.58 75.668 +101.39 75.633 +103.23 75.597 +105.11 75.559 +107.03 75.520 +108.97 75.481 +110.96 75.442 +112.98 75.404 +115.03 75.366 +117.13 75.329 +119.26 75.294 +121.43 75.260 +123.64 75.228 +125.89 75.198 +128.18 75.170 +130.51 75.143 +132.89 75.118 +135.31 75.093 +137.77 75.069 +140.28 75.045 +142.83 75.020 +145.43 74.993 +148.07 74.965 +150.77 74.934 +153.51 74.901 +156.31 74.863 +159.15 74.822 +162.05 74.777 +165.00 74.728 +168.00 74.674 +171.06 74.616 +174.17 74.555 +177.34 74.490 +180.57 74.423 +183.86 74.355 +187.20 74.286 +190.61 74.219 +194.08 74.153 +197.61 74.091 +201.21 74.033 +204.87 73.981 +208.60 73.934 +212.39 73.894 +216.26 73.860 +220.19 73.832 +224.20 73.810 +228.28 73.793 +232.44 73.782 +236.67 73.775 +240.97 73.772 +245.36 73.772 +249.82 73.776 +254.37 73.784 +259.00 73.794 +263.71 73.806 +268.51 73.822 +273.40 73.840 +278.38 73.860 +283.44 73.883 +288.60 73.906 +293.85 73.931 +299.20 73.957 +304.65 73.982 +310.19 74.007 +315.84 74.030 +321.59 74.051 +327.44 74.071 +333.40 74.088 +339.47 74.102 +345.64 74.115 +351.93 74.125 +358.34 74.134 +364.86 74.141 +371.50 74.148 +378.26 74.153 +385.15 74.157 +392.16 74.162 +399.29 74.165 +406.56 74.169 +413.96 74.173 +421.49 74.176 +429.16 74.179 +436.97 74.183 +444.93 74.187 +453.02 74.191 +461.27 74.191 +469.66 74.201 +478.21 74.208 +486.91 74.216 +495.78 74.226 +504.80 74.239 +513.99 74.254 +523.34 74.273 +532.87 74.294 +542.56 74.320 +552.44 74.348 +562.49 74.380 +572.73 74.414 +583.15 74.451 +593.77 74.489 +604.57 74.528 +615.57 74.568 +626.78 74.607 +638.18 74.646 +649.80 74.683 +661.63 74.717 +673.67 74.749 +685.93 74.777 +698.41 74.802 +711.12 74.823 +724.06 74.841 +737.24 74.855 +750.66 74.867 +764.32 74.877 +778.23 74.885 +792.39 74.891 +806.82 74.896 +821.50 74.901 +836.45 74.906 +851.67 74.910 +867.17 74.914 +882.96 74.919 +899.02 74.925 +915.39 74.891 +932.05 74.907 +949.01 74.926 +966.28 74.948 +983.87 74.974 +1001.77 75.002 +1020.00 75.026 +1038.57 75.055 +1057.47 75.088 +1076.71 75.126 +1096.31 75.169 +1116.26 75.218 +1136.58 75.272 +1157.26 75.332 +1178.32 75.400 +1199.77 75.475 +1221.60 75.559 +1243.84 75.652 +1266.47 75.754 +1289.52 75.866 +1312.99 75.987 +1336.89 76.116 +1361.22 76.253 +1385.99 76.396 +1411.22 76.546 +1436.90 76.700 +1463.05 76.859 +1489.68 77.021 +1516.79 77.187 +1544.40 77.357 +1572.50 77.530 +1601.12 77.707 +1630.26 77.887 +1659.93 78.071 +1690.14 78.257 +1720.90 78.446 +1752.22 78.636 +1784.11 78.829 +1816.58 79.024 +1849.64 79.220 +1883.30 79.419 +1917.58 79.620 +1952.48 79.823 +1988.01 80.029 +2024.19 80.237 +2061.03 80.447 +2098.54 80.658 +2136.73 80.869 +2175.62 81.077 +2215.22 81.282 +2255.53 81.481 +2296.58 81.673 +2338.38 81.857 +2380.94 82.030 +2424.27 82.193 +2468.39 82.344 +2513.31 82.483 +2559.05 82.609 +2605.63 82.724 +2653.05 82.826 +2701.33 82.915 +2750.50 82.993 +2800.55 83.059 +2851.52 83.112 +2903.42 83.154 +2956.26 83.182 +3010.06 83.198 +3064.85 83.198 +3120.62 83.184 +3177.42 83.153 +3235.25 83.106 +3294.13 83.041 +3354.08 82.959 +3415.12 82.861 +3477.27 82.749 +3540.56 82.623 +3605.00 82.487 +3670.60 82.343 +3737.41 82.194 +3805.43 82.041 +3874.68 81.887 +3945.20 81.732 +4017.00 81.578 +4090.11 81.422 +4164.55 81.266 +4240.34 81.107 +4317.51 80.944 +4396.09 80.776 +4476.10 80.603 +4557.56 80.423 +4640.50 80.237 +4724.96 80.044 +4810.95 79.846 +4898.51 79.644 +4987.66 79.439 +5078.43 79.232 +5170.86 79.025 +5264.97 78.819 +5360.79 78.616 +5458.35 78.416 +5557.69 78.220 +5658.84 78.029 +5761.82 77.843 +5866.69 77.660 +5973.46 77.482 +6082.17 77.306 +6192.87 77.133 +6305.57 76.961 +6420.33 76.791 +6537.18 76.620 +6656.15 76.451 +6777.29 76.282 +6900.63 76.114 +7026.22 75.951 +7154.10 75.793 +7284.30 75.644 +7416.87 75.508 +7551.85 75.388 +7689.29 75.287 +7829.23 75.208 +7971.72 75.154 +8116.80 75.124 +8264.53 75.117 +8414.94 75.131 +8568.09 75.160 +8724.02 75.198 +8882.79 75.237 +9044.46 75.268 +9209.06 75.282 +9376.66 75.270 +9547.31 75.221 +9721.07 75.129 +9897.99 74.985 +10078.13 74.787 +10261.55 74.530 +10448.30 74.217 +10638.45 73.851 +10832.07 73.438 +11029.21 72.988 +11229.94 72.513 +11434.32 72.025 +11642.41 71.538 +11854.30 71.065 +12070.04 70.618 +12289.71 70.205 +12513.38 69.833 +12741.12 69.503 +12973.00 69.214 +13209.10 68.963 +13449.50 68.744 +13694.28 68.548 +13943.51 68.369 +14197.27 68.200 +14455.66 68.034 +14718.74 67.867 +14986.62 67.694 +15259.37 67.511 +15537.08 67.312 +15819.85 67.090 +16107.76 66.834 +16400.92 66.527 +16699.41 66.147 +17003.33 65.661 +17312.78 65.026 +17627.86 64.186 +17948.68 63.076 +18275.34 61.615 +18607.94 59.722 +18946.60 57.322 +19291.42 54.366 +19642.52 50.866 +20000.00 46.930 +`; + +const RAW_HIFI_ENDGAME_2026 = ` +20.00 84.736 +20.36 84.724 +20.73 84.712 +21.11 84.699 +21.50 84.687 +21.89 84.674 +22.29 84.662 +22.69 84.649 +23.10 84.635 +23.52 84.622 +23.95 84.608 +24.39 84.593 +24.83 84.579 +25.28 84.563 +25.74 84.548 +26.21 84.532 +26.69 84.515 +27.18 84.498 +27.67 84.480 +28.17 84.461 +28.69 84.442 +29.21 84.423 +29.74 84.403 +30.28 84.382 +30.83 84.360 +31.39 84.338 +31.97 84.315 +32.55 84.292 +33.14 84.268 +33.74 84.243 +34.36 84.217 +34.98 84.191 +35.62 84.164 +36.27 84.136 +36.93 84.107 +37.60 84.078 +38.28 84.047 +38.98 84.016 +39.69 83.984 +40.41 83.951 +41.15 83.918 +41.90 83.883 +42.66 83.848 +43.44 83.811 +44.23 83.774 +45.03 83.736 +45.85 83.696 +46.68 83.656 +47.53 83.615 +48.40 83.573 +49.28 83.530 +50.18 83.486 +51.09 83.441 +52.02 83.395 +52.97 83.347 +53.93 83.299 +54.91 83.250 +55.91 83.200 +56.93 83.149 +57.97 83.096 +59.02 83.043 +60.09 82.988 +61.19 82.933 +62.30 82.877 +63.44 82.819 +64.59 82.760 +65.77 82.701 +66.96 82.640 +68.18 82.578 +69.42 82.516 +70.69 82.452 +71.97 82.387 +73.28 82.321 +74.62 82.254 +75.97 82.187 +77.36 82.118 +78.76 82.048 +80.20 81.977 +81.66 81.905 +83.14 81.833 +84.66 81.759 +86.20 81.685 +87.77 81.610 +89.36 81.534 +90.99 81.458 +92.65 81.381 +94.33 81.304 +96.05 81.227 +97.80 81.149 +99.58 81.072 +101.39 80.995 +103.23 80.919 +105.11 80.842 +107.03 80.767 +108.97 80.692 +110.96 80.616 +112.98 80.541 +115.03 80.465 +117.13 80.388 +119.26 80.310 +121.43 80.232 +123.64 80.153 +125.89 80.073 +128.18 79.993 +130.51 79.914 +132.89 79.835 +135.31 79.756 +137.77 79.678 +140.28 79.601 +142.83 79.524 +145.43 79.447 +148.07 79.370 +150.77 79.294 +153.51 79.219 +156.31 79.144 +159.15 79.071 +162.05 78.997 +165.00 78.925 +168.00 78.852 +171.06 78.780 +174.17 78.709 +177.34 78.637 +180.57 78.566 +183.86 78.494 +187.20 78.423 +190.61 78.352 +194.08 78.280 +197.61 78.209 +201.21 78.137 +204.87 78.065 +208.60 77.994 +212.39 77.922 +216.26 77.850 +220.19 77.779 +224.20 77.707 +228.28 77.636 +232.44 77.565 +236.67 77.495 +240.97 77.425 +245.36 77.355 +249.82 77.286 +254.37 77.218 +259.00 77.150 +263.71 77.082 +268.51 77.015 +273.40 76.948 +278.38 76.880 +283.44 76.813 +288.60 76.744 +293.85 76.675 +299.20 76.605 +304.65 76.534 +310.19 76.462 +315.84 76.389 +321.59 76.315 +327.44 76.240 +333.40 76.165 +339.47 76.089 +345.64 76.013 +351.93 75.937 +358.34 75.863 +364.86 75.790 +371.50 75.720 +378.26 75.653 +385.15 75.591 +392.16 75.533 +399.29 75.481 +406.56 75.436 +413.96 75.398 +421.49 75.366 +429.16 75.341 +436.97 75.321 +444.93 75.304 +453.02 75.290 +461.27 75.276 +469.66 75.263 +478.21 75.249 +486.91 75.234 +495.78 75.217 +504.80 75.198 +513.99 75.178 +523.34 75.157 +532.87 75.135 +542.56 75.114 +552.44 75.094 +562.49 75.075 +572.73 75.059 +583.15 75.044 +593.77 75.030 +604.57 75.018 +615.57 75.007 +626.78 74.997 +638.18 74.988 +649.80 74.981 +661.63 74.976 +673.67 74.973 +685.93 74.971 +698.41 74.971 +711.12 74.971 +724.06 74.972 +737.24 74.973 +750.66 74.974 +764.32 74.974 +778.23 74.973 +792.39 74.970 +806.82 74.964 +821.50 74.956 +836.45 74.946 +851.67 74.935 +867.17 74.925 +882.96 74.917 +899.02 74.912 +915.39 74.912 +932.05 74.917 +949.01 74.927 +966.28 74.944 +983.87 74.969 +1001.77 75.004 +1020.00 75.049 +1038.57 75.103 +1057.47 75.168 +1076.71 75.240 +1096.31 75.319 +1116.26 75.402 +1136.58 75.489 +1157.26 75.579 +1178.32 75.673 +1199.77 75.771 +1221.60 75.874 +1243.84 75.983 +1266.47 76.097 +1289.52 76.215 +1312.99 76.336 +1336.89 76.456 +1361.22 76.577 +1385.99 76.696 +1411.22 76.816 +1436.90 76.935 +1463.05 77.057 +1489.68 77.182 +1516.79 77.312 +1544.40 77.453 +1572.50 77.605 +1601.12 77.773 +1630.26 77.956 +1659.93 78.153 +1690.14 78.363 +1720.90 78.583 +1752.22 78.811 +1784.11 79.043 +1816.58 79.278 +1849.64 79.513 +1883.30 79.747 +1917.58 79.977 +1952.48 80.201 +1988.01 80.419 +2024.19 80.631 +2061.03 80.840 +2098.54 81.049 +2136.73 81.265 +2175.62 81.492 +2215.22 81.732 +2255.53 81.981 +2296.58 82.235 +2338.38 82.486 +2380.94 82.726 +2424.27 82.949 +2468.39 83.147 +2513.31 83.315 +2559.05 83.446 +2605.63 83.537 +2653.05 83.588 +2701.33 83.609 +2750.50 83.612 +2800.55 83.606 +2851.52 83.600 +2903.42 83.598 +2956.26 83.603 +3010.06 83.614 +3064.85 83.626 +3120.62 83.635 +3177.42 83.635 +3235.25 83.621 +3294.13 83.593 +3354.08 83.554 +3415.12 83.514 +3477.27 83.478 +3540.56 83.448 +3605.00 83.423 +3670.60 83.400 +3737.41 83.376 +3805.43 83.349 +3874.68 83.321 +3945.20 83.291 +4017.00 83.259 +4090.11 83.223 +4164.55 83.183 +4240.34 83.137 +4317.51 83.085 +4396.09 83.028 +4476.10 82.973 +4557.56 82.923 +4640.50 82.877 +4724.96 82.835 +4810.95 82.797 +4898.51 82.770 +4987.66 82.760 +5078.43 82.769 +5170.86 82.793 +5264.97 82.821 +5360.79 82.848 +5458.35 82.870 +5557.69 82.887 +5658.84 82.899 +5761.82 82.909 +5866.69 82.921 +5973.46 82.938 +6082.17 82.964 +6192.87 83.003 +6305.57 83.053 +6420.33 83.107 +6537.18 83.155 +6656.15 83.198 +6777.29 83.237 +6900.63 83.279 +7026.22 83.329 +7154.10 83.385 +7284.30 83.440 +7416.87 83.490 +7551.85 83.536 +7689.29 83.582 +7829.23 83.616 +7971.72 83.619 +8116.80 83.571 +8264.53 83.471 +8414.94 83.335 +8568.09 83.182 +8724.02 83.024 +8882.79 82.868 +9044.46 82.724 +9209.06 82.596 +9376.66 82.477 +9547.31 82.356 +9721.07 82.227 +9897.99 82.087 +10078.13 81.939 +10261.55 81.784 +10448.30 81.620 +10638.45 81.442 +10832.07 81.251 +11029.21 81.052 +11229.94 80.850 +11434.32 80.648 +11642.41 80.446 +11854.30 80.245 +12070.04 80.042 +12289.71 79.832 +12513.38 79.609 +12741.12 79.371 +12973.00 79.126 +13209.10 78.887 +13449.50 78.664 +13694.28 78.460 +13943.51 78.261 +14197.27 78.058 +14455.66 77.848 +14718.74 77.634 +14986.62 77.419 +15259.37 77.203 +15537.08 76.986 +15819.85 76.769 +16107.76 76.551 +16400.92 76.333 +16699.41 76.113 +17003.33 75.893 +17312.78 75.672 +17627.86 75.451 +17948.68 75.230 +18275.34 75.007 +18607.94 74.775 +18946.60 74.529 +19291.42 74.275 +19642.52 74.036 +20000.00 73.830 +`; +const RAW_HIFI_ENDGAME_2026_MKII = ` +20.00 84.284 +20.36 84.272 +20.73 84.260 +21.11 84.249 +21.50 84.237 +21.89 84.225 +22.29 84.212 +22.69 84.200 +23.10 84.187 +23.52 84.174 +23.95 84.161 +24.39 84.147 +24.83 84.133 +25.28 84.118 +25.74 84.103 +26.21 84.087 +26.69 84.071 +27.18 84.054 +27.67 84.037 +28.17 84.019 +28.69 84.001 +29.21 83.982 +29.74 83.962 +30.28 83.942 +30.83 83.921 +31.39 83.900 +31.97 83.877 +32.55 83.854 +33.14 83.831 +33.74 83.806 +34.36 83.781 +34.98 83.755 +35.62 83.728 +36.27 83.701 +36.93 83.672 +37.60 83.643 +38.28 83.613 +38.98 83.582 +39.69 83.551 +40.41 83.518 +41.15 83.484 +41.90 83.450 +42.66 83.414 +43.44 83.377 +44.23 83.340 +45.03 83.301 +45.85 83.262 +46.68 83.221 +47.53 83.179 +48.40 83.136 +49.28 83.092 +50.18 83.047 +51.09 83.001 +52.02 82.954 +52.97 82.905 +53.93 82.856 +54.91 82.805 +55.91 82.753 +56.93 82.699 +57.97 82.645 +59.02 82.589 +60.09 82.532 +61.19 82.474 +62.30 82.414 +63.44 82.353 +64.59 82.291 +65.77 82.228 +66.96 82.163 +68.18 82.097 +69.42 82.030 +70.69 81.961 +71.97 81.892 +73.28 81.820 +74.62 81.748 +75.97 81.674 +77.36 81.599 +78.76 81.522 +80.20 81.444 +81.66 81.365 +83.14 81.285 +84.66 81.204 +86.20 81.121 +87.77 81.037 +89.36 80.952 +90.99 80.867 +92.65 80.780 +94.33 80.693 +96.05 80.605 +97.80 80.517 +99.58 80.428 +101.39 80.340 +103.23 80.251 +105.11 80.163 +107.03 80.075 +108.97 79.986 +110.96 79.898 +112.98 79.809 +115.03 79.719 +117.13 79.628 +119.26 79.536 +121.43 79.442 +123.64 79.348 +125.89 79.253 +128.18 79.157 +130.51 79.062 +132.89 78.967 +135.31 78.872 +137.77 78.778 +140.28 78.684 +142.83 78.590 +145.43 78.496 +148.07 78.403 +150.77 78.310 +153.51 78.218 +156.31 78.126 +159.15 78.035 +162.05 77.945 +165.00 77.856 +168.00 77.767 +171.06 77.678 +174.17 77.590 +177.34 77.502 +180.57 77.415 +183.86 77.328 +187.20 77.241 +190.61 77.155 +194.08 77.069 +197.61 76.983 +201.21 76.898 +204.87 76.813 +208.60 76.728 +212.39 76.644 +216.26 76.561 +220.19 76.478 +224.20 76.397 +228.28 76.316 +232.44 76.236 +236.67 76.157 +240.97 76.079 +245.36 76.002 +249.82 75.927 +254.37 75.854 +259.00 75.781 +263.71 75.710 +268.51 75.640 +273.40 75.571 +278.38 75.503 +283.44 75.435 +288.60 75.368 +293.85 75.301 +299.20 75.234 +304.65 75.166 +310.19 75.099 +315.84 75.032 +321.59 74.965 +327.44 74.898 +333.40 74.832 +339.47 74.766 +345.64 74.700 +351.93 74.636 +358.34 74.574 +364.86 74.515 +371.50 74.459 +378.26 74.408 +385.15 74.361 +392.16 74.320 +399.29 74.286 +406.56 74.260 +413.96 74.241 +421.49 74.230 +429.16 74.226 +436.97 74.227 +444.93 74.233 +453.02 74.241 +461.27 74.251 +469.66 74.262 +478.21 74.273 +486.91 74.282 +495.78 74.291 +504.80 74.298 +513.99 74.304 +523.34 74.310 +532.87 74.315 +542.56 74.321 +552.44 74.328 +562.49 74.337 +572.73 74.348 +583.15 74.361 +593.77 74.375 +604.57 74.391 +615.57 74.407 +626.78 74.425 +638.18 74.444 +649.80 74.464 +661.63 74.486 +673.67 74.509 +685.93 74.534 +698.41 74.560 +711.12 74.586 +724.06 74.613 +737.24 74.639 +750.66 74.665 +764.32 74.690 +778.23 74.712 +792.39 74.732 +806.82 74.749 +821.50 74.763 +836.45 74.774 +851.67 74.784 +867.17 74.794 +882.96 74.805 +899.02 74.819 +915.39 74.837 +932.05 74.859 +949.01 74.886 +966.28 74.918 +983.87 74.958 +1001.77 75.006 +1020.00 75.064 +1038.57 75.131 +1057.47 75.206 +1076.71 75.289 +1096.31 75.377 +1116.26 75.469 +1136.58 75.564 +1157.26 75.660 +1178.32 75.759 +1199.77 75.861 +1221.60 75.968 +1243.84 76.080 +1266.47 76.195 +1289.52 76.314 +1312.99 76.434 +1336.89 76.553 +1361.22 76.671 +1385.99 76.787 +1411.22 76.901 +1436.90 77.015 +1463.05 77.130 +1489.68 77.246 +1516.79 77.368 +1544.40 77.498 +1572.50 77.639 +1601.12 77.794 +1630.26 77.963 +1659.93 78.145 +1690.14 78.339 +1720.90 78.541 +1752.22 78.750 +1784.11 78.962 +1816.58 79.175 +1849.64 79.388 +1883.30 79.598 +1917.58 79.802 +1952.48 80.000 +1988.01 80.190 +2024.19 80.373 +2061.03 80.550 +2098.54 80.728 +2136.73 80.910 +2175.62 81.103 +2215.22 81.306 +2255.53 81.519 +2296.58 81.735 +2338.38 81.947 +2380.94 82.147 +2424.27 82.329 +2468.39 82.487 +2513.31 82.614 +2559.05 82.704 +2605.63 82.755 +2653.05 82.767 +2701.33 82.752 +2750.50 82.720 +2800.55 82.682 +2851.52 82.647 +2903.42 82.621 +2956.26 82.606 +3010.06 82.600 +3064.85 82.600 +3120.62 82.601 +3177.42 82.598 +3235.25 82.584 +3294.13 82.558 +3354.08 82.526 +3415.12 82.494 +3477.27 82.468 +3540.56 82.449 +3605.00 82.437 +3670.60 82.426 +3737.41 82.415 +3805.43 82.401 +3874.68 82.384 +3945.20 82.365 +4017.00 82.343 +4090.11 82.318 +4164.55 82.286 +4240.34 82.248 +4317.51 82.203 +4396.09 82.152 +4476.10 82.102 +4557.56 82.055 +4640.50 82.013 +4724.96 81.972 +4810.95 81.935 +4898.51 81.908 +4987.66 81.898 +5078.43 81.906 +5170.86 81.928 +5264.97 81.953 +5360.79 81.976 +5458.35 81.995 +5557.69 82.007 +5658.84 82.013 +5761.82 82.018 +5866.69 82.023 +5973.46 82.033 +6082.17 82.052 +6192.87 82.084 +6305.57 82.126 +6420.33 82.171 +6537.18 82.211 +6656.15 82.244 +6777.29 82.274 +6900.63 82.307 +7026.22 82.347 +7154.10 82.393 +7284.30 82.438 +7416.87 82.477 +7551.85 82.513 +7689.29 82.548 +7829.23 82.572 +7971.72 82.564 +8116.80 82.505 +8264.53 82.395 +8414.94 82.249 +8568.09 82.085 +8724.02 81.915 +8882.79 81.749 +9044.46 81.595 +9209.06 81.456 +9376.66 81.326 +9547.31 81.195 +9721.07 81.055 +9897.99 80.905 +10078.13 80.747 +10261.55 80.582 +10448.30 80.407 +10638.45 80.220 +10832.07 80.019 +11029.21 79.810 +11229.94 79.599 +11434.32 79.388 +11642.41 79.177 +11854.30 78.967 +12070.04 78.755 +12289.71 78.537 +12513.38 78.306 +12741.12 78.059 +12973.00 77.806 +13209.10 77.559 +13449.50 77.329 +13694.28 77.117 +13943.51 76.911 +14197.27 76.700 +14455.66 76.484 +14718.74 76.263 +14986.62 76.041 +15259.37 75.819 +15537.08 75.596 +15819.85 75.374 +16107.76 75.150 +16400.92 74.926 +16699.41 74.701 +17003.33 74.476 +17312.78 74.250 +17627.86 74.025 +17948.68 73.799 +18275.34 73.572 +18607.94 73.336 +18946.60 73.086 +19291.42 72.828 +19642.52 72.587 +20000.00 72.378 +`; + + +const RAW_PEQDB_ULTRA = ` +20.00 83.153 +20.36 83.149 +20.73 83.144 +21.11 83.140 +21.50 83.136 +21.89 83.132 +22.29 83.127 +22.69 83.122 +23.10 83.117 +23.52 83.112 +23.95 83.106 +24.39 83.101 +24.83 83.095 +25.28 83.089 +25.74 83.082 +26.21 83.076 +26.69 83.069 +27.18 83.062 +27.67 83.054 +28.17 83.046 +28.69 83.038 +29.21 83.030 +29.74 83.021 +30.28 83.012 +30.83 83.002 +31.39 82.992 +31.97 82.982 +32.55 82.971 +33.14 82.960 +33.74 82.948 +34.36 82.936 +34.98 82.923 +35.62 82.910 +36.27 82.896 +36.93 82.881 +37.60 82.866 +38.28 82.851 +38.98 82.834 +39.69 82.817 +40.41 82.800 +41.15 82.781 +41.90 82.762 +42.66 82.742 +43.44 82.721 +44.23 82.699 +45.03 82.676 +45.85 82.652 +46.68 82.627 +47.53 82.601 +48.40 82.574 +49.28 82.546 +50.18 82.517 +51.09 82.486 +52.02 82.455 +52.97 82.421 +53.93 82.387 +54.91 82.351 +55.91 82.313 +56.93 82.274 +57.97 82.234 +59.02 82.192 +60.09 82.148 +61.19 82.102 +62.30 82.054 +63.44 82.005 +64.59 81.954 +65.77 81.900 +66.96 81.845 +68.18 81.788 +69.42 81.728 +70.69 81.666 +71.97 81.602 +73.28 81.536 +74.62 81.468 +75.97 81.397 +77.36 81.323 +78.76 81.248 +80.20 81.169 +81.66 81.089 +83.14 81.005 +84.66 80.920 +86.20 80.831 +87.77 80.740 +89.36 80.647 +90.99 80.551 +92.65 80.452 +94.33 80.350 +96.05 80.246 +97.80 80.139 +99.58 80.030 +101.39 79.919 +103.23 79.808 +105.11 79.698 +107.03 79.591 +108.97 79.487 +110.96 79.385 +112.98 79.282 +115.03 79.171 +117.13 79.053 +119.26 78.929 +121.43 78.800 +123.64 78.668 +125.89 78.535 +128.18 78.401 +130.51 78.270 +132.89 78.142 +135.31 78.019 +137.77 77.900 +140.28 77.782 +142.83 77.660 +145.43 77.533 +148.07 77.404 +150.77 77.275 +153.51 77.149 +156.31 77.025 +159.15 76.904 +162.05 76.787 +165.00 76.673 +168.00 76.561 +171.06 76.451 +174.17 76.342 +177.34 76.230 +180.57 76.117 +183.86 76.006 +187.20 75.898 +190.61 75.796 +194.08 75.697 +197.61 75.598 +201.21 75.498 +204.87 75.400 +208.60 75.305 +212.39 75.215 +216.26 75.128 +220.19 75.045 +224.20 74.964 +228.28 74.884 +232.44 74.804 +236.67 74.724 +240.97 74.648 +245.36 74.577 +249.82 74.512 +254.37 74.453 +259.00 74.396 +263.71 74.340 +268.51 74.285 +273.40 74.230 +278.38 74.174 +283.44 74.120 +288.60 74.069 +293.85 74.024 +299.20 73.980 +304.65 73.936 +310.19 73.891 +315.84 73.846 +321.59 73.805 +327.44 73.769 +333.40 73.736 +339.47 73.701 +345.64 73.665 +351.93 73.629 +358.34 73.596 +364.86 73.567 +371.50 73.542 +378.26 73.517 +385.15 73.492 +392.16 73.471 +399.29 73.460 +406.56 73.461 +413.96 73.475 +421.49 73.498 +429.16 73.530 +436.97 73.568 +444.93 73.607 +453.02 73.647 +461.27 73.687 +469.66 73.729 +478.21 73.771 +486.91 73.811 +495.78 73.846 +504.80 73.873 +513.99 73.894 +523.34 73.914 +532.87 73.936 +542.56 73.960 +552.44 73.988 +562.49 74.020 +572.73 74.055 +583.15 74.092 +593.77 74.129 +604.57 74.165 +615.57 74.199 +626.78 74.231 +638.18 74.262 +649.80 74.298 +661.63 74.340 +673.67 74.386 +685.93 74.431 +698.41 74.470 +711.12 74.506 +724.06 74.540 +737.24 74.575 +750.66 74.610 +764.32 74.642 +778.23 74.672 +792.39 74.697 +806.82 74.718 +821.50 74.735 +836.45 74.749 +851.67 74.762 +867.17 74.775 +882.96 74.791 +899.02 74.810 +915.39 74.834 +932.05 74.863 +949.01 74.894 +966.28 74.927 +983.87 74.963 +1001.77 75.005 +1020.00 75.055 +1038.57 75.114 +1057.47 75.181 +1076.71 75.256 +1096.31 75.338 +1116.26 75.422 +1136.58 75.508 +1157.26 75.594 +1178.32 75.681 +1199.77 75.769 +1221.60 75.861 +1243.84 75.958 +1266.47 76.061 +1289.52 76.168 +1312.99 76.276 +1336.89 76.381 +1361.22 76.482 +1385.99 76.582 +1411.22 76.680 +1436.90 76.778 +1463.05 76.877 +1489.68 76.978 +1516.79 77.083 +1544.40 77.196 +1572.50 77.320 +1601.12 77.457 +1630.26 77.606 +1659.93 77.768 +1690.14 77.942 +1720.90 78.123 +1752.22 78.310 +1784.11 78.499 +1816.58 78.690 +1849.64 78.880 +1883.30 79.070 +1917.58 79.256 +1952.48 79.437 +1988.01 79.614 +2024.19 79.787 +2061.03 79.958 +2098.54 80.130 +2136.73 80.311 +2175.62 80.506 +2215.22 80.716 +2255.53 80.939 +2296.58 81.172 +2338.38 81.410 +2380.94 81.645 +2424.27 81.870 +2468.39 82.079 +2513.31 82.266 +2559.05 82.429 +2605.63 82.568 +2653.05 82.689 +2701.33 82.795 +2750.50 82.894 +2800.55 82.990 +2851.52 83.086 +2903.42 83.186 +2956.26 83.285 +3010.06 83.383 +3064.85 83.475 +3120.62 83.556 +3177.42 83.621 +3235.25 83.668 +3294.13 83.698 +3354.08 83.711 +3415.12 83.709 +3477.27 83.697 +3540.56 83.677 +3605.00 83.652 +3670.60 83.622 +3737.41 83.585 +3805.43 83.543 +3874.68 83.496 +3945.20 83.444 +4017.00 83.387 +4090.11 83.326 +4164.55 83.260 +4240.34 83.190 +4317.51 83.116 +4396.09 83.039 +4476.10 82.960 +4557.56 82.882 +4640.50 82.809 +4724.96 82.743 +4810.95 82.686 +4898.51 82.642 +4987.66 82.610 +5078.43 82.588 +5170.86 82.574 +5264.97 82.564 +5360.79 82.555 +5458.35 82.546 +5557.69 82.536 +5658.84 82.525 +5761.82 82.516 +5866.69 82.510 +5973.46 82.509 +6082.17 82.514 +6192.87 82.525 +6305.57 82.540 +6420.33 82.557 +6537.18 82.573 +6656.15 82.588 +6777.29 82.605 +6900.63 82.621 +7026.22 82.640 +7154.10 82.660 +7284.30 82.682 +7416.87 82.703 +7551.85 82.719 +7689.29 82.724 +7829.23 82.708 +7971.72 82.659 +8116.80 82.573 +8264.53 82.453 +8414.94 82.309 +8568.09 82.145 +8724.02 81.972 +8882.79 81.797 +9044.46 81.627 +9209.06 81.460 +9376.66 81.296 +9547.31 81.134 +9721.07 80.971 +9897.99 80.803 +10078.13 80.628 +10261.55 80.444 +10448.30 80.250 +10638.45 80.044 +10832.07 79.829 +11029.21 79.609 +11229.94 79.385 +11434.32 79.158 +11642.41 78.928 +11854.30 78.695 +12070.04 78.459 +12289.71 78.217 +12513.38 77.969 +12741.12 77.719 +12973.00 77.470 +13209.10 77.226 +13449.50 76.987 +13694.28 76.752 +13943.51 76.520 +14197.27 76.290 +14455.66 76.060 +14718.74 75.831 +14986.62 75.600 +15259.37 75.369 +15537.08 75.137 +15819.85 74.904 +16107.76 74.672 +16400.92 74.439 +16699.41 74.206 +17003.33 73.974 +17312.78 73.741 +17627.86 73.508 +17948.68 73.274 +18275.34 73.037 +18607.94 72.796 +18946.60 72.549 +19291.42 72.294 +19642.52 72.034 +20000.00 71.771 +`; +const RAW_PEQDB_DIAMOND_BETA = ` +20.00 82.982 +20.36 82.975 +20.73 82.968 +21.11 82.960 +21.50 82.953 +21.89 82.945 +22.29 82.936 +22.69 82.927 +23.10 82.918 +23.52 82.909 +23.95 82.899 +24.39 82.888 +24.83 82.877 +25.28 82.866 +25.74 82.854 +26.21 82.842 +26.69 82.829 +27.18 82.816 +27.67 82.802 +28.17 82.788 +28.69 82.773 +29.21 82.757 +29.74 82.741 +30.28 82.724 +30.83 82.706 +31.39 82.687 +31.97 82.668 +32.55 82.648 +33.14 82.627 +33.74 82.605 +34.36 82.582 +34.98 82.558 +35.62 82.533 +36.27 82.508 +36.93 82.480 +37.60 82.452 +38.28 82.423 +38.98 82.392 +39.69 82.360 +40.41 82.327 +41.15 82.292 +41.90 82.256 +42.66 82.219 +43.44 82.179 +44.23 82.139 +45.03 82.096 +45.85 82.052 +46.68 82.006 +47.53 81.958 +48.40 81.908 +49.28 81.856 +50.18 81.802 +51.09 81.746 +52.02 81.688 +52.97 81.628 +53.93 81.566 +54.91 81.501 +55.91 81.434 +56.93 81.364 +57.97 81.292 +59.02 81.217 +60.09 81.140 +61.19 81.061 +62.30 80.978 +63.44 80.894 +64.59 80.806 +65.77 80.716 +66.96 80.623 +68.18 80.528 +69.42 80.430 +70.69 80.329 +71.97 80.225 +73.28 80.119 +74.62 80.011 +75.97 79.900 +77.36 79.786 +78.76 79.670 +80.20 79.552 +81.66 79.432 +83.14 79.309 +84.66 79.183 +86.20 79.056 +87.77 78.928 +89.36 78.796 +90.99 78.664 +92.65 78.530 +94.33 78.395 +96.05 78.258 +97.80 78.122 +99.58 77.986 +101.39 77.849 +103.23 77.715 +105.11 77.580 +107.03 77.449 +108.97 77.318 +110.96 77.188 +112.98 77.058 +115.03 76.928 +117.13 76.798 +119.26 76.667 +121.43 76.535 +123.64 76.404 +125.89 76.273 +128.18 76.143 +130.51 76.016 +132.89 75.892 +135.31 75.771 +137.77 75.653 +140.28 75.538 +142.83 75.425 +145.43 75.314 +148.07 75.205 +150.77 75.099 +153.51 74.997 +156.31 74.898 +159.15 74.802 +162.05 74.711 +165.00 74.622 +168.00 74.536 +171.06 74.454 +174.17 74.375 +177.34 74.300 +180.57 74.226 +183.86 74.155 +187.20 74.086 +190.61 74.020 +194.08 73.957 +197.61 73.896 +201.21 73.837 +204.87 73.780 +208.60 73.727 +212.39 73.676 +216.26 73.626 +220.19 73.579 +224.20 73.535 +228.28 73.493 +232.44 73.454 +236.67 73.418 +240.97 73.383 +245.36 73.351 +249.82 73.322 +254.37 73.295 +259.00 73.271 +263.71 73.248 +268.51 73.228 +273.40 73.209 +278.38 73.192 +283.44 73.175 +288.60 73.159 +293.85 73.143 +299.20 73.128 +304.65 73.113 +310.19 73.097 +315.84 73.082 +321.59 73.067 +327.44 73.053 +333.40 73.038 +339.47 73.024 +345.64 73.010 +351.93 72.996 +358.34 72.985 +364.86 72.976 +371.50 72.970 +378.26 72.967 +385.15 72.970 +392.16 72.977 +399.29 72.990 +406.56 73.010 +413.96 73.037 +421.49 73.071 +429.16 73.111 +436.97 73.156 +444.93 73.205 +453.02 73.255 +461.27 73.306 +469.66 73.356 +478.21 73.406 +486.91 73.455 +495.78 73.501 +504.80 73.546 +513.99 73.588 +523.34 73.629 +532.87 73.669 +542.56 73.709 +552.44 73.748 +562.49 73.789 +572.73 73.830 +583.15 73.871 +593.77 73.914 +604.57 73.957 +615.57 74.000 +626.78 74.043 +638.18 74.087 +649.80 74.132 +661.63 74.177 +673.67 74.222 +685.93 74.268 +698.41 74.313 +711.12 74.358 +724.06 74.402 +737.24 74.445 +750.66 74.486 +764.32 74.525 +778.23 74.561 +792.39 74.595 +806.82 74.624 +821.50 74.651 +836.45 74.676 +851.67 74.700 +867.17 74.722 +882.96 74.745 +899.02 74.770 +915.39 74.798 +932.05 74.829 +949.01 74.864 +966.28 74.906 +983.87 74.953 +1001.77 75.006 +1020.00 75.067 +1038.57 75.136 +1057.47 75.212 +1076.71 75.293 +1096.31 75.381 +1116.26 75.472 +1136.58 75.568 +1157.26 75.666 +1178.32 75.767 +1199.77 75.870 +1221.60 75.977 +1243.84 76.087 +1266.47 76.198 +1289.52 76.310 +1312.99 76.424 +1336.89 76.537 +1361.22 76.651 +1385.99 76.763 +1411.22 76.878 +1436.90 76.993 +1463.05 77.111 +1489.68 77.234 +1516.79 77.362 +1544.40 77.497 +1572.50 77.641 +1601.12 77.796 +1630.26 77.961 +1659.93 78.137 +1690.14 78.322 +1720.90 78.515 +1752.22 78.715 +1784.11 78.919 +1816.58 79.127 +1849.64 79.337 +1883.30 79.547 +1917.58 79.757 +1952.48 79.968 +1988.01 80.179 +2024.19 80.390 +2061.03 80.604 +2098.54 80.821 +2136.73 81.043 +2175.62 81.272 +2215.22 81.507 +2255.53 81.745 +2296.58 81.986 +2338.38 82.223 +2380.94 82.455 +2424.27 82.676 +2468.39 82.882 +2513.31 83.072 +2559.05 83.242 +2605.63 83.390 +2653.05 83.520 +2701.33 83.630 +2750.50 83.725 +2800.55 83.808 +2851.52 83.881 +2903.42 83.944 +2956.26 84.001 +3010.06 84.049 +3064.85 84.088 +3120.62 84.116 +3177.42 84.134 +3235.25 84.139 +3294.13 84.130 +3354.08 84.111 +3415.12 84.080 +3477.27 84.042 +3540.56 83.996 +3605.00 83.944 +3670.60 83.886 +3737.41 83.823 +3805.43 83.757 +3874.68 83.686 +3945.20 83.613 +4017.00 83.536 +4090.11 83.457 +4164.55 83.375 +4240.34 83.293 +4317.51 83.209 +4396.09 83.126 +4476.10 83.047 +4557.56 82.971 +4640.50 82.902 +4724.96 82.839 +4810.95 82.784 +4898.51 82.739 +4987.66 82.702 +5078.43 82.675 +5170.86 82.656 +5264.97 82.644 +5360.79 82.635 +5458.35 82.631 +5557.69 82.629 +5658.84 82.628 +5761.82 82.631 +5866.69 82.637 +5973.46 82.644 +6082.17 82.656 +6192.87 82.673 +6305.57 82.693 +6420.33 82.716 +6537.18 82.742 +6656.15 82.770 +6777.29 82.798 +6900.63 82.828 +7026.22 82.856 +7154.10 82.880 +7284.30 82.901 +7416.87 82.912 +7551.85 82.912 +7689.29 82.897 +7829.23 82.864 +7971.72 82.810 +8116.80 82.733 +8264.53 82.634 +8414.94 82.515 +8568.09 82.378 +8724.02 82.230 +8882.79 82.072 +9044.46 81.908 +9209.06 81.742 +9376.66 81.573 +9547.31 81.400 +9721.07 81.226 +9897.99 81.047 +10078.13 80.864 +10261.55 80.674 +10448.30 80.479 +10638.45 80.277 +10832.07 80.068 +11029.21 79.853 +11229.94 79.632 +11434.32 79.407 +11642.41 79.177 +11854.30 78.943 +12070.04 78.705 +12289.71 78.465 +12513.38 78.222 +12741.12 77.977 +12973.00 77.732 +13209.10 77.488 +13449.50 77.247 +13694.28 77.007 +13943.51 76.772 +14197.27 76.537 +14455.66 76.304 +14718.74 76.071 +14986.62 75.840 +15259.37 75.608 +15537.08 75.377 +15819.85 75.145 +16107.76 74.913 +16400.92 74.681 +16699.41 74.446 +17003.33 74.212 +17312.78 73.976 +17627.86 73.739 +17948.68 73.500 +18275.34 73.259 +18607.94 73.016 +18946.60 72.771 +19291.42 72.524 +19642.52 72.276 +20000.00 72.028 +`; + + +const RAW_SEAP = ` +20.00 82.982 +20.36 82.975 +20.73 82.969 +21.11 82.962 +21.50 82.957 +21.89 82.951 +22.29 82.943 +22.69 82.936 +23.10 82.928 +23.52 82.921 +23.95 82.912 +24.39 82.905 +24.83 82.896 +25.28 82.887 +25.74 82.876 +26.21 82.867 +26.69 82.857 +27.18 82.847 +27.67 82.835 +28.17 82.823 +28.69 82.810 +29.21 82.798 +29.74 82.784 +30.28 82.770 +30.83 82.755 +31.39 82.739 +31.97 82.723 +32.55 82.706 +33.14 82.681 +33.74 82.666 +34.36 82.649 +34.98 82.628 +35.62 82.607 +36.27 82.584 +36.93 82.559 +37.60 82.534 +38.28 82.508 +38.98 82.480 +39.69 82.452 +40.41 82.421 +41.15 82.390 +41.90 82.356 +42.66 82.321 +43.44 82.285 +44.23 82.246 +45.03 82.206 +45.85 82.163 +46.68 82.119 +47.53 82.072 +48.40 82.024 +49.28 81.974 +50.18 81.922 +51.09 81.865 +52.02 81.808 +52.97 81.747 +53.93 81.684 +54.91 81.618 +55.91 81.549 +56.93 81.477 +57.97 81.404 +59.02 81.325 +60.09 81.245 +61.19 81.161 +62.30 81.073 +63.44 80.986 +64.59 80.893 +65.77 80.797 +66.96 80.699 +68.18 80.599 +69.42 80.495 +70.69 80.390 +71.97 80.282 +73.28 80.171 +74.62 80.060 +75.97 79.946 +77.36 79.830 +78.76 79.712 +80.20 79.593 +81.66 79.473 +83.14 79.350 +84.66 79.228 +86.20 79.103 +87.77 78.978 +89.36 78.852 +90.99 78.725 +92.65 78.596 +94.33 78.466 +96.05 78.336 +97.80 78.205 +99.58 78.074 +101.39 77.942 +103.23 77.812 +105.11 77.684 +107.03 77.562 +108.97 77.443 +110.96 77.329 +112.98 77.215 +115.03 77.094 +117.13 76.968 +119.26 76.837 +121.43 76.701 +123.64 76.565 +125.89 76.428 +128.18 76.291 +130.51 76.158 +132.89 76.029 +135.31 75.905 +137.77 75.786 +140.28 75.669 +142.83 75.549 +145.43 75.423 +148.07 75.297 +150.77 75.170 +153.51 75.047 +156.31 74.926 +159.15 74.809 +162.05 74.696 +165.00 74.585 +168.00 74.477 +171.06 74.372 +174.17 74.265 +177.34 74.158 +180.57 74.049 +183.86 73.942 +187.20 73.837 +190.61 73.740 +194.08 73.644 +197.61 73.548 +201.21 73.452 +204.87 73.357 +208.60 73.265 +212.39 73.178 +216.26 73.094 +220.19 73.013 +224.20 72.934 +228.28 72.857 +232.44 72.780 +236.67 72.702 +240.97 72.626 +245.36 72.557 +249.82 72.494 +254.37 72.437 +259.00 72.380 +263.71 72.326 +268.51 72.272 +273.40 72.217 +278.38 72.162 +283.44 72.109 +288.60 72.058 +293.85 72.013 +299.20 71.970 +304.65 71.926 +310.19 71.881 +315.84 71.835 +321.59 71.794 +327.44 71.759 +333.40 71.725 +339.47 71.690 +345.64 71.653 +351.93 71.617 +358.34 71.583 +364.86 71.554 +371.50 71.528 +378.26 71.502 +385.15 71.476 +392.16 71.456 +399.29 71.444 +406.56 71.444 +413.96 71.457 +421.49 71.479 +429.16 71.511 +436.97 71.548 +444.93 71.586 +453.02 71.626 +461.27 71.664 +469.66 71.705 +478.21 71.747 +486.91 71.786 +495.78 71.821 +504.80 71.846 +513.99 71.867 +523.34 71.887 +532.87 71.907 +542.56 71.931 +552.44 71.959 +562.49 71.990 +572.73 72.024 +583.15 72.060 +593.77 72.097 +604.57 72.132 +615.57 72.166 +626.78 72.197 +638.18 72.228 +649.80 72.263 +661.63 72.306 +673.67 72.351 +685.93 72.395 +698.41 72.434 +711.12 72.470 +724.06 72.504 +737.24 72.538 +750.66 72.573 +764.32 72.605 +778.23 72.635 +792.39 72.659 +806.82 72.680 +821.50 72.697 +836.45 72.711 +851.67 72.723 +867.17 72.736 +882.96 72.752 +899.02 72.771 +915.39 72.795 +932.05 72.823 +949.01 72.855 +966.28 72.888 +983.87 72.923 +1001.77 72.965 +1020.00 73.015 +1038.57 73.074 +1057.47 73.141 +1076.71 73.216 +1096.31 73.298 +1116.26 73.381 +1136.58 73.467 +1157.26 73.553 +1178.32 73.642 +1199.77 73.730 +1221.60 73.823 +1243.84 73.920 +1266.47 74.025 +1289.52 74.134 +1312.99 74.244 +1336.89 74.353 +1361.22 74.459 +1385.99 74.565 +1411.22 74.670 +1436.90 74.776 +1463.05 74.886 +1489.68 75.000 +1516.79 75.120 +1544.40 75.251 +1572.50 75.396 +1601.12 75.557 +1630.26 75.734 +1659.93 75.928 +1690.14 76.139 +1720.90 76.360 +1752.22 76.591 +1784.11 76.829 +1816.58 77.074 +1849.64 77.323 +1883.30 77.578 +1917.58 77.834 +1952.48 78.092 +1988.01 78.351 +2024.19 78.614 +2061.03 78.882 +2098.54 79.158 +2136.73 79.451 +2175.62 79.764 +2215.22 80.098 +2255.53 80.450 +2296.58 80.813 +2338.38 81.179 +2380.94 81.534 +2424.27 81.868 +2468.39 82.167 +2513.31 82.421 +2559.05 82.624 +2605.63 82.774 +2653.05 82.878 +2701.33 82.943 +2750.50 82.979 +2800.55 82.997 +2851.52 83.003 +2903.42 83.005 +2956.26 83.001 +3010.06 82.995 +3064.85 82.983 +3120.62 82.962 +3177.42 82.929 +3235.25 82.882 +3294.13 82.822 +3354.08 82.751 +3415.12 82.670 +3477.27 82.585 +3540.56 82.496 +3605.00 82.408 +3670.60 82.319 +3737.41 82.228 +3805.43 82.137 +3874.68 82.044 +3945.20 81.949 +4017.00 81.853 +4090.11 81.756 +4164.55 81.657 +4240.34 81.556 +4317.51 81.454 +4396.09 81.351 +4476.10 81.248 +4557.56 81.147 +4640.50 81.054 +4724.96 80.968 +4810.95 80.894 +4898.51 80.833 +4987.66 80.786 +5078.43 80.749 +5170.86 80.722 +5264.97 80.699 +5360.79 80.679 +5458.35 80.659 +5557.69 80.638 +5658.84 80.618 +5761.82 80.600 +5866.69 80.585 +5973.46 80.576 +6082.17 80.574 +6192.87 80.578 +6305.57 80.586 +6420.33 80.597 +6537.18 80.607 +6656.15 80.617 +6777.29 80.628 +6900.63 80.639 +7026.22 80.654 +7154.10 80.669 +7284.30 80.687 +7416.87 80.704 +7551.85 80.716 +7689.29 80.718 +7829.23 80.698 +7971.72 80.646 +8116.80 80.557 +8264.53 80.434 +8414.94 80.287 +8568.09 80.120 +8724.02 79.945 +8882.79 79.768 +9044.46 79.595 +9209.06 79.426 +9376.66 79.260 +9547.31 79.096 +9721.07 78.931 +9897.99 78.761 +10078.13 78.585 +10261.55 78.399 +10448.30 78.203 +10638.45 77.996 +10832.07 77.779 +11029.21 77.558 +11229.94 77.333 +11434.32 77.104 +11642.41 76.873 +11854.30 76.639 +12070.04 76.402 +12289.71 76.159 +12513.38 75.910 +12741.12 75.659 +12973.00 75.409 +13209.10 75.164 +13449.50 74.924 +13694.28 74.689 +13943.51 74.456 +14197.27 74.225 +14455.66 73.994 +14718.74 73.765 +14986.62 73.533 +15259.37 73.301 +15537.08 73.069 +15819.85 72.835 +16107.76 72.603 +16400.92 72.369 +16699.41 72.136 +17003.33 71.903 +17312.78 71.670 +17627.86 71.437 +17948.68 71.202 +18275.34 70.965 +18607.94 70.724 +18946.60 70.476 +19291.42 70.221 +19642.52 69.961 +20000.00 69.695 +`; + +const RAW_SEAP_BASS = ` +20.00 84.785 +20.36 84.777 +20.73 84.768 +21.11 84.759 +21.50 84.751 +21.89 84.741 +22.29 84.730 +22.69 84.719 +23.10 84.707 +23.52 84.694 +23.95 84.680 +24.39 84.667 +24.83 84.651 +25.28 84.634 +25.74 84.616 +26.21 84.599 +26.69 84.581 +27.18 84.560 +27.67 84.539 +28.17 84.516 +28.69 84.492 +29.21 84.468 +29.74 84.441 +30.28 84.413 +30.83 84.384 +31.39 84.353 +31.97 84.321 +32.55 84.287 +33.14 84.251 +33.74 84.213 +34.36 84.174 +34.98 84.132 +35.62 84.090 +36.27 84.044 +36.93 83.995 +37.60 83.945 +38.28 83.894 +38.98 83.838 +39.69 83.781 +40.41 83.722 +41.15 83.659 +41.90 83.595 +42.66 83.527 +43.44 83.457 +44.23 83.383 +45.03 83.307 +45.85 83.229 +46.68 83.147 +47.53 83.064 +48.40 82.978 +49.28 82.890 +50.18 82.800 +51.09 82.706 +52.02 82.612 +52.97 82.516 +53.93 82.418 +54.91 82.318 +55.91 82.217 +56.93 82.114 +57.97 82.012 +59.02 81.906 +60.09 81.800 +61.19 81.692 +62.30 81.582 +63.44 81.474 +64.59 81.361 +65.77 81.247 +66.96 81.130 +68.18 81.012 +69.42 80.889 +70.69 80.765 +71.97 80.638 +73.28 80.507 +74.62 80.374 +75.97 80.235 +77.36 80.093 +78.76 79.947 +80.20 79.797 +81.66 79.642 +83.14 79.482 +84.66 79.319 +86.20 79.149 +87.77 78.975 +89.36 78.796 +90.99 78.612 +92.65 78.422 +94.33 78.225 +96.05 78.024 +97.80 77.817 +99.58 77.604 +101.39 77.386 +103.23 77.164 +105.11 76.940 +107.03 76.718 +108.97 76.494 +110.96 76.270 +112.98 76.044 +115.03 75.808 +117.13 75.566 +119.26 75.318 +121.43 75.067 +123.64 74.817 +125.89 74.572 +128.18 74.334 +130.51 74.107 +132.89 73.895 +135.31 73.701 +137.77 73.527 +140.28 73.369 +142.83 73.225 +145.43 73.092 +148.07 72.975 +150.77 72.874 +153.51 72.791 +156.31 72.724 +159.15 72.672 +162.05 72.633 +165.00 72.604 +168.00 72.584 +171.06 72.569 +174.17 72.557 +177.34 72.543 +180.57 72.528 +183.86 72.512 +187.20 72.497 +190.61 72.486 +194.08 72.473 +197.61 72.456 +201.21 72.436 +204.87 72.412 +208.60 72.387 +212.39 72.364 +216.26 72.339 +220.19 72.314 +224.20 72.287 +228.28 72.259 +232.44 72.227 +236.67 72.191 +240.97 72.155 +245.36 72.122 +249.82 72.092 +254.37 72.066 +259.00 72.039 +263.71 72.011 +268.51 71.982 +273.40 71.951 +278.38 71.916 +283.44 71.883 +288.60 71.850 +293.85 71.822 +299.20 71.795 +304.65 71.765 +310.19 71.733 +315.84 71.700 +321.59 71.670 +327.44 71.646 +333.40 71.622 +339.47 71.595 +345.64 71.567 +351.93 71.538 +358.34 71.511 +364.86 71.488 +371.50 71.468 +378.26 71.448 +385.15 71.427 +392.16 71.411 +399.29 71.403 +406.56 71.407 +413.96 71.424 +421.49 71.449 +429.16 71.484 +436.97 71.524 +444.93 71.565 +453.02 71.607 +461.27 71.648 +469.66 71.691 +478.21 71.734 +486.91 71.775 +495.78 71.811 +504.80 71.838 +513.99 71.860 +523.34 71.880 +532.87 71.902 +542.56 71.927 +552.44 71.955 +562.49 71.987 +572.73 72.022 +583.15 72.059 +593.77 72.096 +604.57 72.132 +615.57 72.166 +626.78 72.198 +638.18 72.228 +649.80 72.264 +661.63 72.307 +673.67 72.352 +685.93 72.397 +698.41 72.435 +711.12 72.472 +724.06 72.505 +737.24 72.540 +750.66 72.575 +764.32 72.607 +778.23 72.637 +792.39 72.661 +806.82 72.683 +821.50 72.699 +836.45 72.713 +851.67 72.725 +867.17 72.739 +882.96 72.754 +899.02 72.773 +915.39 72.797 +932.05 72.825 +949.01 72.856 +966.28 72.890 +983.87 72.925 +1001.77 72.967 +1020.00 73.017 +1038.57 73.075 +1057.47 73.142 +1076.71 73.217 +1096.31 73.299 +1116.26 73.382 +1136.58 73.468 +1157.26 73.555 +1178.32 73.643 +1199.77 73.731 +1221.60 73.824 +1243.84 73.921 +1266.47 74.026 +1289.52 74.135 +1312.99 74.245 +1336.89 74.354 +1361.22 74.459 +1385.99 74.565 +1411.22 74.670 +1436.90 74.776 +1463.05 74.886 +1489.68 75.000 +1516.79 75.120 +1544.40 75.251 +1572.50 75.396 +1601.12 75.557 +1630.26 75.734 +1659.93 75.928 +1690.14 76.138 +1720.90 76.359 +1752.22 76.590 +1784.11 76.828 +1816.58 77.073 +1849.64 77.322 +1883.30 77.577 +1917.58 77.833 +1952.48 78.091 +1988.01 78.350 +2024.19 78.613 +2061.03 78.881 +2098.54 79.157 +2136.73 79.450 +2175.62 79.763 +2215.22 80.097 +2255.53 80.449 +2296.58 80.812 +2338.38 81.178 +2380.94 81.533 +2424.27 81.867 +2468.39 82.166 +2513.31 82.419 +2559.05 82.622 +2605.63 82.772 +2653.05 82.876 +2701.33 82.941 +2750.50 82.977 +2800.55 82.995 +2851.52 83.001 +2903.42 83.003 +2956.26 82.999 +3010.06 82.993 +3064.85 82.981 +3120.62 82.960 +3177.42 82.927 +3235.25 82.880 +3294.13 82.820 +3354.08 82.749 +3415.12 82.668 +3477.27 82.583 +3540.56 82.494 +3605.00 82.406 +3670.60 82.317 +3737.41 82.226 +3805.43 82.135 +3874.68 82.042 +3945.20 81.947 +4017.00 81.851 +4090.11 81.754 +4164.55 81.655 +4240.34 81.554 +4317.51 81.452 +4396.09 81.349 +4476.10 81.246 +4557.56 81.145 +4640.50 81.052 +4724.96 80.966 +4810.95 80.892 +4898.51 80.831 +4987.66 80.784 +5078.43 80.747 +5170.86 80.720 +5264.97 80.697 +5360.79 80.677 +5458.35 80.657 +5557.69 80.636 +5658.84 80.616 +5761.82 80.598 +5866.69 80.583 +5973.46 80.574 +6082.17 80.572 +6192.87 80.576 +6305.57 80.584 +6420.33 80.595 +6537.18 80.605 +6656.15 80.615 +6777.29 80.626 +6900.63 80.637 +7026.22 80.652 +7154.10 80.667 +7284.30 80.685 +7416.87 80.702 +7551.85 80.714 +7689.29 80.716 +7829.23 80.696 +7971.72 80.644 +8116.80 80.555 +8264.53 80.432 +8414.94 80.285 +8568.09 80.118 +8724.02 79.943 +8882.79 79.766 +9044.46 79.593 +9209.06 79.424 +9376.66 79.258 +9547.31 79.094 +9721.07 78.929 +9897.99 78.759 +10078.13 78.583 +10261.55 78.397 +10448.30 78.201 +10638.45 77.994 +10832.07 77.777 +11029.21 77.556 +11229.94 77.331 +11434.32 77.102 +11642.41 76.871 +11854.30 76.637 +12070.04 76.400 +12289.71 76.157 +12513.38 75.908 +12741.12 75.657 +12973.00 75.407 +13209.10 75.162 +13449.50 74.922 +13694.28 74.687 +13943.51 74.454 +14197.27 74.223 +14455.66 73.992 +14718.74 73.763 +14986.62 73.531 +15259.37 73.299 +15537.08 73.067 +15819.85 72.833 +16107.76 72.601 +16400.92 72.367 +16699.41 72.134 +17003.33 71.901 +17312.78 71.668 +17627.86 71.435 +17948.68 71.200 +18275.34 70.963 +18607.94 70.722 +18946.60 70.474 +19291.42 70.219 +19642.52 69.959 +20000.00 69.695 +`; + +const RAW_HARMAN_SPEAKER = ` +20.00 81.000 +25.00 81.000 +31.50 81.000 +40.00 81.000 +50.00 81.000 +63.00 81.000 +80.00 81.000 +100.00 80.800 +125.00 80.300 +160.00 79.500 +200.00 78.500 +250.00 77.500 +315.00 76.500 +400.00 75.800 +500.00 75.400 +630.00 75.100 +800.00 75.050 +1000.00 75.000 +1250.00 74.700 +1600.00 74.300 +2000.00 73.800 +2500.00 73.200 +3150.00 72.500 +4000.00 71.700 +5000.00 70.900 +6300.00 70.000 +8000.00 69.000 +10000.00 68.000 +12500.00 67.000 +16000.00 66.000 +20000.00 65.000 +`; + +const RAW_FLAT_LINE = ` +20.00 75.000 +20000.00 75.000 +`; + + +// --- PARSER --- +/** + * Parse raw frequency/gain text data into [{freq, gain}] arrays + * Supports semicolon, comma, tab, and whitespace delimiters + * Handles European decimal format and header detection + * @param {string} raw - Raw text data + * @returns {Array<{freq: number, gain: number}>} + */ +function parseRawData(raw) { + if (!raw) return []; + + const lines = raw.trim().split('\n'); + if (lines.length === 0) return []; + + const points = []; + const firstLine = lines[0].trim(); + + // Determine separator + let delimiter = /\s+/; + if (firstLine.indexOf(';') > -1) delimiter = ';'; + else if (firstLine.indexOf(',') > -1) delimiter = ','; + else if (firstLine.indexOf('\t') > -1) delimiter = '\t'; + + // Determine column indices + let freqIdx = 0; + let gainIdx = 1; + + const hasHeader = /[a-zA-Z]/.test(firstLine); + if (hasHeader) { + const headers = firstLine.split(delimiter).map(h => h.trim().toLowerCase().replace(/['"]+/g, '')); + const fIdx = headers.findIndex(h => h.includes('freq') || h === 'f'); + if (fIdx > -1) freqIdx = fIdx; + + const rIdx = headers.findIndex(h => h === 'raw'); + if (rIdx > -1) { + gainIdx = rIdx; + } else { + const splIdx = headers.findIndex(h => h.includes('spl') || h.includes('gain') || h.includes('db') || h.includes('mag')); + if (splIdx > -1 && splIdx !== freqIdx) gainIdx = splIdx; + } + } + + for (const line of lines) { + const cleanLine = line.trim(); + if (!cleanLine) continue; + if (!/^[\d\-.]/u.test(cleanLine)) continue; + + const parts = typeof delimiter === 'string' ? cleanLine.split(delimiter) : cleanLine.split(delimiter); + if (parts.length <= Math.max(freqIdx, gainIdx)) continue; + + let freqStr = parts[freqIdx].trim(); + let gainStr = parts[gainIdx].trim(); + + if (delimiter !== ',') { + if (freqStr.includes(',')) freqStr = freqStr.replace(',', '.'); + if (gainStr.includes(',')) gainStr = gainStr.replace(',', '.'); + } + + const freq = parseFloat(freqStr); + const gain = parseFloat(gainStr); + + if (!isNaN(freq) && !isNaN(gain)) { + points.push({ freq, gain }); + } + } + + return points.sort((a, b) => a.freq - b.freq); +} + +// --- PARSED TARGET CURVES --- +const TARGETS = [ + { id: 'harman_oe_2018', label: 'Harman Over-Ear 2018', data: parseRawData(RAW_HARMAN_OE_2018) }, + { id: 'harman_ie_2019', label: 'Harman In-Ear 2019', data: parseRawData(RAW_HARMAN_IE_2019) }, + { id: 'diffuse_field', label: 'Diffuse Field', data: parseRawData(RAW_DIFFUSE_FIELD) }, + { id: 'knowles', label: 'Knowles', data: parseRawData(RAW_KNOWLES) }, + { id: 'moondrop', label: 'Moondrop VDSF', data: parseRawData(RAW_MOONDROP_VDSF) }, + { id: 'hifi_endgame', label: 'HiFi Endgame 2026', data: parseRawData(RAW_HIFI_ENDGAME_2026) }, + { id: 'hifi_endgame_mkii', label: 'HiFi Endgame 2026 MKII', data: parseRawData(RAW_HIFI_ENDGAME_2026_MKII) }, + { id: 'peqdb_ultra', label: 'PEQdB Ultra', data: parseRawData(RAW_PEQDB_ULTRA) }, + { id: 'peqdb_diamond_beta', label: 'PEQdB Diamond β', data: parseRawData(RAW_PEQDB_DIAMOND_BETA) }, + { id: 'seap', label: 'SEAP', data: parseRawData(RAW_SEAP) }, + { id: 'seap_bass', label: 'SEAP Bass', data: parseRawData(RAW_SEAP_BASS) }, + { id: 'flat', label: 'Flat (Calibration)', data: parseRawData(RAW_FLAT_LINE) }, +]; + +const SPEAKER_TARGETS = [ + { id: 'harman_room', label: 'Harman In-Room (2013)', data: parseRawData(RAW_HARMAN_SPEAKER) }, + { id: 'seap_bass', label: 'SEAP Bass (Room)', data: parseRawData(RAW_SEAP_BASS) }, + { id: 'flat', label: 'Flat', data: parseRawData(RAW_FLAT_LINE) }, +]; + +export { parseRawData, TARGETS, SPEAKER_TARGETS }; diff --git a/js/autoeq-engine.js b/js/autoeq-engine.js new file mode 100644 index 0000000..6a9fa7f --- /dev/null +++ b/js/autoeq-engine.js @@ -0,0 +1,221 @@ +// js/autoeq-engine.js +// AutoEQ Algorithm - Ported from Seap Engine AutoEqEngine.ts +// Iterative peak-flattening parametric EQ optimization + +// Constants +const MAX_BOOST = 30.0; +const MAX_CUT = 30.0; +const MIN_Q = 0.6; +const DEFAULT_SR = 48000; +const PI = Math.PI; +const DB_BASE = 10; +const DB_DIVISOR = 40; + +/** + * Calculate biquad filter magnitude response at a given frequency + * @param {number} f - Frequency to evaluate (Hz) + * @param {object} band - EQ band {type, freq, gain, q, enabled} + * @param {number} sr - Sample rate + * @returns {number} Magnitude in dB + */ +function calculateBiquadResponse(f, band, sr = DEFAULT_SR) { + if (!band.enabled) return 0; + if (!band.type || band.type.length === 0) return 0; + const w = 2 * PI * band.freq / sr; + const p = 2 * PI * f / sr; + const s = Math.sin(w) / (2 * band.q); + const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR); + const c = Math.cos(w); + let b0 = 0, b1 = 0, b2 = 0, a0 = 0, a1 = 0, a2 = 0; + + const t = band.type[0]; + + if (t === 'p') { + b0 = 1 + s * A; b1 = -2 * c; b2 = 1 - s * A; + a0 = 1 + s / A; a1 = -2 * c; a2 = 1 - s / A; + } else if (t === 'l') { + const sq = 2 * Math.sqrt(A) * s; + b0 = A * ((A + 1) - (A - 1) * c + sq); + b1 = 2 * A * ((A - 1) - (A + 1) * c); + b2 = A * ((A + 1) - (A - 1) * c - sq); + a0 = (A + 1) + (A - 1) * c + sq; + a1 = -2 * ((A - 1) + (A + 1) * c); + a2 = (A + 1) + (A - 1) * c - sq; + } else if (t === 'h') { + const sq = 2 * Math.sqrt(A) * s; + b0 = A * ((A + 1) + (A - 1) * c + sq); + b1 = -2 * A * ((A - 1) + (A + 1) * c); + b2 = A * ((A + 1) + (A - 1) * c - sq); + a0 = (A + 1) - (A - 1) * c + sq; + a1 = 2 * ((A - 1) - (A + 1) * c); + a2 = (A + 1) - (A - 1) * c - sq; + } else { + return 0; + } + + const _a0 = 1 / a0; + const b0n = b0 * _a0, b1n = b1 * _a0, b2n = b2 * _a0; + const a1n = a1 * _a0, a2n = a2 * _a0; + const cp = Math.cos(p), c2p = Math.cos(2 * p); + const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p; + const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p; + return 10 * Math.log10(n / d); +} + +/** + * Linear interpolation on frequency response data + * @param {number} freq - Frequency to interpolate at + * @param {Array<{freq: number, gain: number}>} data - Frequency response data + * @returns {number} Interpolated gain value + */ +function interpolate(freq, data) { + if (data.length === 0) return 0; + if (freq <= data[0].freq) return data[0].gain; + if (freq >= data[data.length - 1].freq) return data[data.length - 1].gain; + for (let i = 0; i < data.length - 1; i++) { + if (freq >= data[i].freq && freq <= data[i + 1].freq) { + return data[i].gain + (freq - data[i].freq) / (data[i + 1].freq - data[i].freq) * (data[i + 1].gain - data[i].gain); + } + } + return 0; +} + +/** + * Calculate normalization offset based on midrange average (250-2500 Hz) + * @param {Array<{freq: number, gain: number}>} data - Frequency response data + * @returns {number} Average gain in midrange + */ +function getNormalizationOffset(data) { + let sum = 0, count = 0; + for (const p of data) { + if (p.freq >= 250 && p.freq <= 2500) { + sum += p.gain; + count++; + } + } + return count > 0 ? sum / count : interpolate(1000, data); +} + +/** + * Run the AutoEQ algorithm to generate parametric EQ bands + * Iterative peak-flattening: finds largest error, places a corrective filter, repeats + * + * @param {Array<{freq: number, gain: number}>} measurement - Headphone frequency response + * @param {Array<{freq: number, gain: number}>} target - Target frequency response curve + * @param {number} bandCount - Number of EQ bands to generate + * @param {number} maxFreq - Maximum frequency limit (Hz) + * @param {number} minFreq - Minimum frequency limit (Hz) + * @param {number} maxQ - Maximum Q factor + * @returns {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} + */ +function runAutoEqAlgorithm(measurement, target, bandCount, maxFreq = 16000, minFreq = 20, maxQ = 5.0, sampleRate = DEFAULT_SR) { + if (minFreq > maxFreq) return []; + const off = getNormalizationOffset(target) - getNormalizationOffset(measurement); + let err = measurement.map(p => ({ freq: p.freq, gain: (p.gain + off) - interpolate(p.freq, target) })); + + const hasInRangePoints = err.some(p => p.freq >= minFreq && p.freq <= maxFreq); + if (!hasInRangePoints) return []; + + const out = []; + + for (let i = 0; i < bandCount; i++) { + let maxDev = 0, maxWeightedDev = 0, peakFreq = 1000, peakIdx = 0; + + // Scan for maximum weighted error + for (let j = 0; j < err.length; j++) { + const p = err[j]; + if (p.freq < minFreq || p.freq > maxFreq) continue; + + // 3-point smoothing + let v = p.gain; + if (j > 0 && j < err.length - 1) { + v = (err[j - 1].gain + v + err[j + 1].gain) / 3; + } + + // Frequency-dependent weighting + let w = 1.0; + if (p.freq < 300) w = 1.5; + else if (p.freq < 4000) w = 1.0; + else if (p.freq < 8000) w = 0.5; + else w = 0.25; + + if (Math.abs(v * w) > Math.abs(maxWeightedDev)) { + maxWeightedDev = Math.abs(v * w); + maxDev = v; + peakFreq = p.freq; + peakIdx = j; + } + } + + let gain = -maxDev; + + // Safety clamps - reduce max boost at higher frequencies + let safeBoost = MAX_BOOST; + if (peakFreq > 3000) safeBoost = 6.0; + if (peakFreq > 6000) safeBoost = 3.0; + if (gain > safeBoost) gain = safeBoost; + if (gain < -MAX_CUT) gain = -MAX_CUT; + if (Math.abs(gain) < 0.2) break; + + // Q factor calculation from error bandwidth (half-gain points) + let upperFreq = peakFreq, lowerFreq = peakFreq; + let foundLower = false, foundUpper = false; + const thresholdError = maxDev / 2; + for (let k = peakIdx; k >= 0; k--) { + if (Math.abs(err[k].gain) < Math.abs(thresholdError)) { + lowerFreq = err[k].freq; + foundLower = true; + break; + } + } + for (let k = peakIdx; k < err.length; k++) { + if (Math.abs(err[k].gain) < Math.abs(thresholdError)) { + upperFreq = err[k].freq; + foundUpper = true; + break; + } + } + + // If half-gain boundary not found on one side, mirror the other side + // to avoid degenerate bandwidth = 0 producing extremely narrow filters + if (!foundLower && foundUpper) { + lowerFreq = peakFreq * peakFreq / upperFreq; + } else if (!foundUpper && foundLower) { + upperFreq = peakFreq * peakFreq / lowerFreq; + } else if (!foundLower && !foundUpper) { + // Neither boundary found — use 1 octave default + lowerFreq = peakFreq / Math.SQRT2; + upperFreq = peakFreq * Math.SQRT2; + } + + let bandwidth = Math.log2(upperFreq / Math.max(1, lowerFreq)); + if (bandwidth < 0.1) bandwidth = 0.1; + let q = Math.sqrt(Math.pow(2, bandwidth)) / (Math.pow(2, bandwidth) - 1); + q = Math.max(MIN_Q, Math.min(maxQ, q)); + if (peakFreq > 5000 && q > 3.0) q = 3.0; + if (gain > 0 && q > 2.0) q = 2.0; + + const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true }; + + // Check cumulative gain at the peak frequency across all existing bands + this one + let cumulativeGain = gain; + for (const existing of out) { + cumulativeGain += calculateBiquadResponse(peakFreq, existing, sampleRate); + } + // If cumulative boost exceeds safe limits, reduce this band's gain + const cumulativeLimit = MAX_BOOST; + if (cumulativeGain > cumulativeLimit) { + newBand.gain = gain - (cumulativeGain - cumulativeLimit); + if (newBand.gain < 0.2) continue; + } + + out.push(newBand); + + // Update error curve by applying the new band's response + err = err.map(p => ({ ...p, gain: p.gain + calculateBiquadResponse(p.freq, newBand, sampleRate) })); + } + + return out.sort((a, b) => a.freq - b.freq).map((b, i) => ({ ...b, id: i })); +} + +export { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm }; diff --git a/js/autoeq-importer.js b/js/autoeq-importer.js new file mode 100644 index 0000000..0cd1cf0 --- /dev/null +++ b/js/autoeq-importer.js @@ -0,0 +1,219 @@ +// js/autoeq-importer.js +// Headphone Database Browser - Fetches from AutoEq GitHub repository +// Provides access to 4000+ headphone measurement profiles + +import { parseRawData } from './autoeq-data.js'; +import { db } from './db.js'; + +const CACHE_KEY = 'autoeq_index_v4'; +const OLD_LS_CACHE_KEY = 'monochrome_autoeq_index_v4'; +const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours + +// 5 most popular headphones — pre-loaded as defaults and shown in the headphone select +// All measured on Rtings B&K 5128 rig for consistency +const POPULAR_HEADPHONES = [ + { name: 'Sony WH-1000XM5 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sony WH-1000XM5', fileName: 'Sony WH-1000XM5.csv' }, + { name: 'Apple AirPods Pro2 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Apple AirPods Pro2', fileName: 'Apple AirPods Pro2.csv' }, + { name: 'Sony WF-1000XM5 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' }, + { name: 'Samsung Galaxy Buds3 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' }, + { name: 'Sennheiser HD 600 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' }, +]; + +// Static fallback list in case GitHub API fails — popular picks + additional well-known models +const FALLBACK_INDEX = [ + ...POPULAR_HEADPHONES, + { name: 'Sennheiser HD 600 (Filk)', type: 'over-ear', path: 'Filk/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' }, + { name: 'Sennheiser HD 600 (Innerfidelity)', type: 'over-ear', path: 'Innerfidelity/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' }, + { name: 'Samsung Galaxy Buds2 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds2 Pro', fileName: 'Samsung Galaxy Buds2 Pro.csv' }, + { name: 'Sony WF-1000XM5 (Kazi)', type: 'in-ear', path: 'Kazi/in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' }, + { name: 'Samsung Galaxy Buds3 Pro (DHRME)', type: 'in-ear', path: 'DHRME/in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' }, + { name: 'Apple AirPods Pro (Super Review)', type: 'in-ear', path: 'Super Review/in-ear/Apple AirPods Pro', fileName: 'Apple AirPods Pro.csv' }, + { name: 'Sennheiser HD 600 (2020) (Kuulokenurkka)', type: 'over-ear', path: 'Kuulokenurkka/over-ear/Sennheiser HD 600 (2020)', fileName: 'Sennheiser HD 600 (2020).csv' }, +]; + +/** + * Fetch the full AutoEq headphone index from GitHub + * Uses GitHub API to get the repository tree, then parses it for measurement files + * Caches results in localStorage for 24 hours + * @returns {Promise>} + */ +async function fetchAutoEqIndex() { + // Migrate: remove old localStorage cache to free quota + try { localStorage.removeItem(OLD_LS_CACHE_KEY); } catch { /* ignore */ } + + // 1. Try loading from IndexedDB cache + try { + const cached = await db.getSetting(CACHE_KEY); + if (cached && cached.timestamp && cached.data) { + if (Date.now() - cached.timestamp < CACHE_EXPIRY) { + console.log('[AutoEQ] Loaded index from cache'); + return cached.data; + } + } + } catch (e) { + console.warn('[AutoEQ] Failed to read cache:', e); + } + + // 2. Fetch from GitHub API + try { + console.log('[AutoEQ] Fetching index from GitHub...'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + let response; + try { + response = await fetch('https://api.github.com/repos/jaakkopasanen/AutoEq/git/trees/master?recursive=1', { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + try { + const cached = await db.getSetting(CACHE_KEY); + if (cached?.data) { + console.warn('[AutoEQ] GitHub API limit reached. Using stale cache.'); + return cached.data; + } + } catch { /* ignore */ } + console.warn('[AutoEQ] GitHub API error. Using fallback.'); + return FALLBACK_INDEX; + } + + const data = await response.json(); + const entries = []; + + for (const item of data.tree) { + if (!item.path.startsWith('results/')) continue; + if (!item.path.endsWith('.csv') && !item.path.endsWith('.txt')) continue; + + const parts = item.path.split('/'); + if (parts.length < 4) continue; + + const fileName = parts.pop(); + const fileNameLower = fileName.toLowerCase(); + + // Skip non-measurement files (EQ presets, not raw frequency response) + if (fileNameLower.includes('parametriceq') || + fileNameLower.includes('fixedbandeq') || + fileNameLower.includes('graphiceq') || + fileNameLower.includes('convolution') || + fileNameLower.includes('fixed band eq') || + fileNameLower.includes('parametric eq') || + fileNameLower.includes('graphic eq')) { + continue; + } + + const headphoneName = parts[parts.length - 1]; + const folderPath = parts.slice(1).join('/'); + const source = parts[1]; + + let type = 'over-ear'; + const lowerPath = item.path.toLowerCase(); + if (lowerPath.includes('in-ear') || lowerPath.includes('iem')) { + type = 'in-ear'; + } else if (lowerPath.includes('earbud')) { + type = 'in-ear'; + } + + entries.push({ + name: `${headphoneName} (${source})`, + type, + path: folderPath, + fileName, + }); + } + + if (entries.length === 0) return FALLBACK_INDEX; + + const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name)); + + // 3. Save to IndexedDB cache + try { + await db.saveSetting(CACHE_KEY, { + timestamp: Date.now(), + data: sortedEntries, + }); + console.log(`[AutoEQ] Cached ${sortedEntries.length} entries`); + } catch (e) { + console.warn('[AutoEQ] Failed to save cache:', e); + } + + return sortedEntries; + } catch (err) { + if (err.name === 'AbortError') { + console.warn('[AutoEQ] GitHub API request timed out. Falling back to cache or fallback index.'); + try { + const cached = await db.getSetting(CACHE_KEY); + if (cached?.data) return cached.data; + } catch { /* ignore */ } + } else { + console.error('[AutoEQ] Failed to fetch index:', err); + } + return FALLBACK_INDEX; + } +} + +/** + * Fetch the frequency response measurement data for a specific headphone + * Tries raw GitHub first, falls back to jsDelivr CDN + * @param {object} entry - AutoEq entry {name, type, path, fileName} + * @returns {Promise>} + */ +async function fetchHeadphoneData(entry) { + const encodedPath = entry.path.split('/').map(encodeURIComponent).join('/'); + const encodedFileName = encodeURIComponent(entry.fileName); + + const urls = [ + `https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/${encodedPath}/${encodedFileName}`, + `https://cdn.jsdelivr.net/gh/jaakkopasanen/AutoEq@master/results/${encodedPath}/${encodedFileName}`, + ]; + + for (const url of urls) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + let response; + try { + response = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + if (!response.ok) continue; + + const text = await response.text(); + // Validate it's not an HTML error page + if (text.trim().startsWith(' 0) return points; + } catch (e) { + console.warn(`[AutoEQ] Fetch failed for ${url}:`, e); + } + } + + throw new Error(`Failed to fetch data for ${entry.name}`); +} + +/** + * Search/filter headphone entries by query and optional type filter + * @param {string} query - Search query + * @param {Array} entries - Full list of entries + * @param {string} typeFilter - Optional type filter ('all', 'over-ear', 'in-ear') + * @param {number} limit - Maximum results to return + * @returns {Array} + */ +function searchHeadphones(query, entries, typeFilter = 'all', limit = 100) { + let filtered = entries; + + if (typeFilter !== 'all') { + filtered = filtered.filter(e => e.type === typeFilter); + } + + if (query && query.trim()) { + const lower = query.toLowerCase().trim(); + filtered = filtered.filter(e => e.name.toLowerCase().includes(lower)); + } + + return filtered.slice(0, limit); +} + +export { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES }; diff --git a/js/equalizer.js b/js/equalizer.js index 09b70d8..cc2cd86 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -621,8 +621,9 @@ export class Equalizer { this.frequencies.forEach((freq, index) => { const gain = this.currentGains[index] || 0; + const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index); const filterNum = index + 1; - lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`); + lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`); }); return lines.join('\n'); @@ -680,16 +681,25 @@ export class Equalizer { this.setBandCount(newCount); } - // Extract gains from filters - const gains = filters.slice(0, this.bandCount).map((f) => f.gain); - this.setAllGains(gains); + // Apply imported filter frequencies directly instead of regenerating + const sliced = filters.slice(0, this.bandCount); + const newFreqs = sliced.map((f) => f.freq); + this.frequencies = newFreqs; + this.frequencyLabels = generateFrequencyLabels(newFreqs); - // Store filter frequencies if different - const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); - if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { - equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); + // Update filter frequencies on the actual biquad nodes + if (this.filters.length === newFreqs.length) { + newFreqs.forEach((freq, i) => { + if (this.filters[i]) { + this.filters[i].frequency.value = freq; + } + }); } + // Extract and apply gains + const gains = sliced.map((f) => f.gain); + this.setAllGains(gains); + return true; } catch (e) { console.warn('[Equalizer] Failed to import settings:', e); diff --git a/js/player.js b/js/player.js index 4e0ed01..de0b850 100644 --- a/js/player.js +++ b/js/player.js @@ -16,7 +16,6 @@ import { exponentialVolumeSettings, audioEffectsSettings, radioSettings, - playbackSettings, } from './storage.js'; import { audioContextManager } from './audio-context.js'; import { isIos, isSafari } from './platform-detection.js'; @@ -49,7 +48,6 @@ export class Player { this.repeatMode = REPEAT_MODE.OFF; this.preloadCache = new Map(); this.preloadAbortController = null; - this._lastPreloadTime = null; this.currentTrack = null; this.currentRgValues = null; this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7'); @@ -108,6 +106,7 @@ export class Player { bufferingGoal: 30, rebufferingGoal: 2, bufferBehind: 30, + jumpLargeGaps: true, }, abr: { enabled: true, @@ -151,6 +150,7 @@ export class Player { document.addEventListener('visibilitychange', () => { const el = this.activeElement; if (document.visibilityState === 'visible' && !el.paused) { + // Ensure audio context is resumed when user returns to the app if (!audioContextManager.isReady()) { audioContextManager.init(el); } @@ -162,17 +162,6 @@ export class Player { } }); - // Time-based preload trigger for Safari background playback - this._timeUpdateHandler = this._handleTimeUpdateForPreload.bind(this); - this.audio.addEventListener('timeupdate', this._timeUpdateHandler); - if (this.video) { - this.video.addEventListener('timeupdate', this._timeUpdateHandler); - } - - window.addEventListener('preload-time-change', () => { - this._lastPreloadTime = null; - }); - this._setupVideoSync(); } @@ -527,21 +516,6 @@ export class Player { } } - _handleTimeUpdateForPreload() { - const el = this.activeElement; - if (!el || !el.duration || el.paused) return; - - const preloadTime = playbackSettings.getPreloadTime(); - const timeRemaining = el.duration - el.currentTime; - if (timeRemaining <= preloadTime && timeRemaining > 0) { - const now = Date.now(); - if (!this._lastPreloadTime || now - this._lastPreloadTime > 5000) { - this._lastPreloadTime = now; - this.preloadNextTracks(); - } - } - } - async setupHlsVideo(video, result, fallbackImg) { const url = result.videoUrl || result.hlsUrl || result; const Hls = (await import('hls.js')).default; diff --git a/js/settings.js b/js/settings.js index 7cf8e44..40c6d6b 100644 --- a/js/settings.js +++ b/js/settings.js @@ -18,7 +18,6 @@ import { visualizerSettings, playlistSettings, equalizerSettings, - playbackSettings, listenBrainzSettings, malojaSettings, libreFmSettings, @@ -37,7 +36,10 @@ import { modalSettings, preferDolbyAtmosSettings, } from './storage.js'; -import { audioContextManager, EQ_PRESETS } from './audio-context.js'; +import { audioContextManager, getPresetsForBandCount } from './audio-context.js'; +import { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm } from './autoeq-engine.js'; +import { parseRawData, TARGETS, SPEAKER_TARGETS } from './autoeq-data.js'; +import { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES } from './autoeq-importer.js'; import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; @@ -49,6 +51,9 @@ async function getButterchurnPresets(...args) { return butterchurnModule.getButterchurnPresets(...args); } +// Module-level state for AutoEQ (persists across re-initializations) +let _autoeqIndex = []; + export async function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab const savedTab = settingsUiState.getActiveTab(); @@ -1112,27 +1117,6 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } - // Fullscreen Cover Tilt Toggle - const fullscreenTiltToggle = document.getElementById('fullscreen-tilt-toggle'); - if (fullscreenTiltToggle) { - fullscreenTiltToggle.checked = playbackSettings.isFullscreenTiltEnabled(); - fullscreenTiltToggle.addEventListener('change', (e) => { - playbackSettings.setFullscreenTiltEnabled(e.target.checked); - window.dispatchEvent(new CustomEvent('fullscreen-tilt-toggle', { detail: { enabled: e.target.checked } })); - }); - } - - // Preload Time Input - const preloadTimeInput = document.getElementById('preload-time-input'); - if (preloadTimeInput) { - preloadTimeInput.value = playbackSettings.getPreloadTime(); - preloadTimeInput.addEventListener('change', (e) => { - const val = Math.max(5, Math.min(60, parseInt(e.target.value, 10) || 15)); - playbackSettings.setPreloadTime(val); - window.dispatchEvent(new CustomEvent('preload-time-change', { detail: { seconds: val } })); - }); - } - // ReplayGain Settings const replayGainMode = document.getElementById('replay-gain-mode'); if (replayGainMode) { @@ -1244,1042 +1228,3288 @@ export async function initializeSettings(scrobbler, player, api, ui) { } // ======================================== - // Parametric Equalizer Settings (3-32 bands with custom ranges) + // Precision AutoEQ — Redesigned Equalizer // ======================================== const eqToggle = document.getElementById('equalizer-enabled-toggle'); const eqContainer = document.getElementById('equalizer-container'); - const eqPresetSelect = document.getElementById('equalizer-preset-select'); - const eqResetBtn = document.getElementById('equalizer-reset-btn'); - const eqBandsContainer = document.getElementById('equalizer-bands'); - const customPresetsOptgroup = document.getElementById('custom-presets-optgroup'); - const customPresetNameInput = document.getElementById('custom-preset-name'); - const saveCustomPresetBtn = document.getElementById('save-custom-preset-btn'); - const deleteCustomPresetBtn = document.getElementById('delete-custom-preset-btn'); - const eqBandCountInput = document.getElementById('eq-band-count'); - const eqRangeMinInput = document.getElementById('eq-range-min'); - const eqRangeMaxInput = document.getElementById('eq-range-max'); - const applyEqRangeBtn = document.getElementById('apply-eq-range-btn'); - const eqFreqMinInput = document.getElementById('eq-freq-min'); - const eqFreqMaxInput = document.getElementById('eq-freq-max'); - const applyEqFreqBtn = document.getElementById('apply-eq-freq-btn'); - const resetEqFreqBtn = document.getElementById('reset-eq-freq-btn'); - const resetEqRangeBtn = document.getElementById('reset-eq-range-btn'); - const eqScaleContainer = document.querySelector('.equalizer-scale'); const eqPreampSlider = document.getElementById('eq-preamp-slider'); - const eqPreampInput = document.getElementById('eq-preamp-input'); - const eqExportBtn = document.getElementById('eq-export-btn'); - const eqImportBtn = document.getElementById('eq-import-btn'); - const eqImportFile = document.getElementById('eq-import-file'); - // Current settings - let currentBandCount = equalizerSettings.getBandCount(); - let currentRange = equalizerSettings.getRange(); - let currentFreqRange = equalizerSettings.getFreqRange(); + // AutoEQ State (kept when switching modes) + let autoeqSelectedMeasurement = null; + let autoeqSelectedEntry = null; + let autoeqCurrentBands = null; // AutoEQ-generated bands + let autoeqCorrectedCurve = null; let currentPreamp = equalizerSettings.getPreamp(); - /** - * Generate frequency labels for given band count and frequency range - */ - const generateFreqLabels = (count, minFreq = currentFreqRange.min, maxFreq = currentFreqRange.max) => { - const labels = []; - const safeMin = Math.max(10, minFreq); - const safeMax = Math.min(96000, maxFreq); + // Parametric EQ State (separate from AutoEQ, kept when switching modes) + let parametricBands = null; - for (let i = 0; i < count; i++) { - const t = i / (count - 1); - const freq = safeMin * Math.pow(safeMax / safeMin, t); - const rounded = Math.round(freq); + // Interactive graph state + let draggedNode = null; + let hoveredNode = null; + let graphAnimFrame = null; - if (rounded < 1000) { - labels.push(rounded.toString()); - } else if (rounded < 10000) { - labels.push((rounded / 1000).toFixed(rounded % 1000 === 0 ? 0 : 1) + 'K'); - } else { - labels.push((rounded / 1000).toFixed(0) + 'K'); + // dB zoom state (half-range values, user-adjustable via scroll on Y axis) + let graphDbHalfAutoEQ = 25; + let graphDbHalfParametric = 35; + + /** Get the active bands for the current mode */ + const getActiveBands = () => { + if (currentMode === 'parametric') return parametricBands; + if (currentMode === 'speaker') return speakerChannels[speakerActiveChannel]?.bands || null; + return autoeqCurrentBands; + }; + /** Set the active bands for the current mode */ + const setActiveBands = (bands) => { + if (currentMode === 'parametric') parametricBands = bands; + else if (currentMode === 'speaker') speakerChannels[speakerActiveChannel].bands = bands; + else autoeqCurrentBands = bands; + }; + + // DOM Elements + const autoeqCanvas = document.getElementById('autoeq-response-canvas'); + const autoeqGraphWrapper = document.getElementById('autoeq-graph-wrapper'); + const autoeqHeadphoneSelect = document.getElementById('autoeq-headphone-select'); + const autoeqTargetSelect = document.getElementById('autoeq-target-select'); + const autoeqBandCount = document.getElementById('autoeq-band-count'); + const autoeqMaxFreq = document.getElementById('autoeq-max-freq'); + const autoeqSampleRate = document.getElementById('autoeq-sample-rate'); + + // Safely set band count dropdown, ensuring the value matches an available option + const setAutoeqBandCount = (count, bands) => { + if (!autoeqBandCount) return; + const val = String(count || (bands && bands.length) || 10); + autoeqBandCount.value = val; + // If value didn't match any option (dropdown shows blank), add it or fall back + if (autoeqBandCount.value !== val) { + // Try using actual band count from the bands array + if (bands && bands.length) { + const bandsVal = String(bands.length); + autoeqBandCount.value = bandsVal; + if (autoeqBandCount.value === bandsVal) return; } - } - - return labels; - }; - - /** - * Generate EQ bands HTML - */ - const generateEQBands = ( - count, - rangeMin = currentRange.min, - rangeMax = currentRange.max, - freqMin = currentFreqRange.min, - freqMax = currentFreqRange.max - ) => { - if (!eqBandsContainer) return; - - const labels = generateFreqLabels(count, freqMin, freqMax); - eqBandsContainer.innerHTML = ''; - - for (let i = 0; i < count; i++) { - const bandEl = document.createElement('div'); - bandEl.className = 'eq-band'; - bandEl.dataset.band = i; - - bandEl.innerHTML = ` - - 0 - ${labels[i]} - `; - - eqBandsContainer.appendChild(bandEl); - } - - // Re-initialize band sliders - initializeBandSliders(); - }; - - /** - * Update EQ scale display - */ - const updateEQScale = (min, max) => { - if (!eqScaleContainer) return; - const spans = eqScaleContainer.querySelectorAll('span'); - if (spans.length >= 3) { - spans[0].textContent = `+${max} dB`; - spans[1].textContent = '0 dB'; - spans[2].textContent = `${min} dB`; + // Fall back to default + autoeqBandCount.value = '10'; } }; + const autoeqRunBtn = document.getElementById('autoeq-run-btn'); + const autoeqDownloadBtn = document.getElementById('autoeq-download-btn'); + const autoeqStatus = document.getElementById('autoeq-status'); + const autoeqImportBtn = document.getElementById('autoeq-import-measurement-btn'); + const autoeqImportFile = document.getElementById('autoeq-import-measurement-file'); + const autoeqSavedGrid = document.getElementById('autoeq-saved-grid'); + const autoeqSavedCount = document.getElementById('autoeq-saved-count'); + const autoeqProfileNameInput = document.getElementById('autoeq-profile-name'); + const autoeqSaveBtn = document.getElementById('autoeq-save-btn'); + const autoeqSavedCollapse = document.getElementById('autoeq-saved-collapse'); + const autoeqDatabaseList = document.getElementById('autoeq-database-list'); + const autoeqDatabaseCount = document.getElementById('autoeq-database-count'); + const autoeqFiltersToggle = document.getElementById('autoeq-filters-toggle'); + const autoeqFiltersContent = document.getElementById('autoeq-filters-content'); + const autoeqFiltersCollapse = document.getElementById('autoeq-filters-collapse'); + const autoeqBandsList = document.getElementById('autoeq-bands-list'); + const autoeqPreampValue = document.getElementById('autoeq-preamp-value'); - /** - * Update the visual display of a band value - */ - const updateBandValueDisplay = (bandEl, value) => { - const valueEl = bandEl.querySelector('.eq-value'); - if (!valueEl) return; - - const displayValue = value > 0 ? `+${value}` : value.toString(); - valueEl.textContent = displayValue; - - // Add color classes based on value - valueEl.classList.remove('positive', 'negative'); - if (value > 0) { - valueEl.classList.add('positive'); - } else if (value < 0) { - valueEl.classList.add('negative'); + // Populate headphone select with popular models + if (autoeqHeadphoneSelect) { + const optgroup = document.createElement('optgroup'); + optgroup.label = 'Popular'; + for (const hp of POPULAR_HEADPHONES) { + const opt = document.createElement('option'); + opt.value = hp.name; + opt.textContent = hp.name.replace(/\s*\([^)]*\)\s*$/, ''); // strip source suffix for clean display + opt.dataset.type = hp.type; + optgroup.appendChild(opt); } - }; + // Insert after the placeholder option + autoeqHeadphoneSelect.appendChild(optgroup); - /** - * Update all band sliders and displays from an array of gains - */ - const updateAllBandUI = (gains) => { - const eqBands = eqBandsContainer?.querySelectorAll('.eq-band'); - if (!eqBands) return; - - eqBands.forEach((bandEl, index) => { - const slider = bandEl.querySelector('.eq-slider'); - if (slider && gains[index] !== undefined) { - slider.value = gains[index]; - updateBandValueDisplay(bandEl, gains[index]); + // When user picks a popular headphone from the dropdown, load it + autoeqHeadphoneSelect.addEventListener('change', () => { + const selected = autoeqHeadphoneSelect.value; + if (!selected) return; + const popularEntry = POPULAR_HEADPHONES.find((hp) => hp.name === selected); + if (popularEntry && (!autoeqSelectedEntry || autoeqSelectedEntry.name !== selected)) { + loadHeadphoneEntry(popularEntry); } }); + } - // Redraw the EQ curve after updating all bands - drawEQCurve(); + // ======================================== + // Frequency Response Graph Renderer + // ======================================== + const FREQ_MIN = 20; + const FREQ_MAX = 20000; + const GRAPH_FREQS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; + const LOG_MIN = Math.log10(FREQ_MIN); + const LOG_MAX = Math.log10(FREQ_MAX); + const LOG_RANGE = LOG_MAX - LOG_MIN; + + const freqToX = (freq, width) => ((Math.log10(Math.max(FREQ_MIN, freq)) - LOG_MIN) / LOG_RANGE) * width; + const xToFreq = (x, width) => Math.pow(10, (x / width) * LOG_RANGE + LOG_MIN); + const dbToY = (db, height, dbMin, dbMax) => height - ((db - dbMin) / (dbMax - dbMin)) * height; + const yToDb = (y, height, dbMin, dbMax) => dbMin + (1 - y / height) * (dbMax - dbMin); + + const formatFreq = (freq) => { + if (freq >= 1000) return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'k'; + return Math.round(freq).toString(); }; /** - * Toggle EQ container visibility + * Draw the frequency response graph with Original, Target, and Corrected curves */ - const updateEQContainerVisibility = (enabled) => { - if (eqContainer) { - eqContainer.style.display = enabled ? 'block' : 'none'; - if (enabled) { - // Redraw curve when container becomes visible - requestAnimationFrame(drawEQCurve); + const drawAutoEQGraph = () => { + if (!autoeqCanvas) return; + const activeBands = getActiveBands(); + const ctx = autoeqCanvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = autoeqCanvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + autoeqCanvas.width = rect.width * dpr; + autoeqCanvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; + const w = rect.width - padLeft - padRight; + const h = rect.height - padTop - padBottom; + + ctx.clearRect(0, 0, rect.width, rect.height); + + // dB scale: fixed 75dB center for AutoEQ, 0dB center for Parametric + const isParametricMode = currentMode === 'parametric'; + const dbCenter = isParametricMode ? 0 : 75; + const dbHalfRange = isParametricMode ? graphDbHalfParametric : graphDbHalfAutoEQ; + const dbMin = dbCenter - dbHalfRange; + const dbMax = dbCenter + dbHalfRange; + + // Helper mappings (local to graph area) + const gx = (freq) => padLeft + freqToX(freq, w); + const gy = (db) => padTop + dbToY(db, h, dbMin, dbMax); + + // Fixed curve colors (work across all themes) + const gridColor = 'rgba(255,255,255,0.06)'; + const textColor = 'rgba(255,255,255,0.4)'; + const originalColor = '#3b82f6'; // Blue + const targetColor = 'rgba(255,255,255,0.5)'; // White/gray dashed + const correctedColor = '#f472b6'; // Pink + + // Draw grid + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + // Horizontal grid lines (dB) + for (let db = dbMin; db <= dbMax; db += 5) { + const y = gy(db); + ctx.beginPath(); + ctx.moveTo(padLeft, y); + ctx.lineTo(padLeft + w, y); + ctx.stroke(); + } + // Vertical grid lines (freq) + for (const freq of GRAPH_FREQS) { + const x = gx(freq); + ctx.beginPath(); + ctx.moveTo(x, padTop); + ctx.lineTo(x, padTop + h); + ctx.stroke(); + } + + // Y axis labels + ctx.fillStyle = textColor; + ctx.font = '10px system-ui, sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let db = dbMin; db <= dbMax; db += 5) { + ctx.fillText(db.toString(), padLeft - 5, gy(db)); + } + + // X axis labels + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (const freq of GRAPH_FREQS) { + ctx.fillText(formatFreq(freq), gx(freq), padTop + h + 8); + } + + // Draw curve helper + const drawCurve = (data, color, lineWidth, dashed = false) => { + if (!data || data.length < 2) return; + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + if (dashed) ctx.setLineDash([6, 4]); + let started = false; + for (const p of data) { + if (p.freq < FREQ_MIN || p.freq > FREQ_MAX) continue; + const x = gx(p.freq); + const y = gy(p.gain); + if (!started) { + ctx.moveTo(x, y); + started = true; + } else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + }; + + // Normalize all data to center around dbCenter + let targetId, targetEntry, targetData, graphMeasurement; + if (currentMode === 'speaker') { + const sCh = speakerChannels[speakerActiveChannel]; + targetId = sCh?.targetId || 'harman_room'; + targetEntry = SPEAKER_TARGETS.find((t) => t.id === targetId); + targetData = targetEntry?.data; + graphMeasurement = sCh?.measurement; + } else { + targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018'; + targetEntry = TARGETS.find((t) => t.id === targetId); + targetData = targetEntry?.data; + graphMeasurement = autoeqSelectedMeasurement; + } + + let graphShift = 0; + + if (isParametricMode) { + // Parametric mode: flat 0dB reference line + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(padLeft, gy(0)); + ctx.lineTo(padLeft + w, gy(0)); + ctx.stroke(); + + if (activeBands && activeBands.length > 0) { + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + const nodeColors = [ + '#f472b6', + '#fb923c', + '#facc15', + '#4ade80', + '#22d3ee', + '#818cf8', + '#c084fc', + '#f87171', + '#34d399', + '#60a5fa', + '#a78bfa', + '#fb7185', + '#fbbf24', + '#2dd4bf', + '#38bdf8', + '#a3e635', + ]; + + // Draw individual band bell curves (filled) + activeBands.forEach((band, i) => { + if (!band.enabled || Math.abs(band.gain) < 0.1) return; + const color = nodeColors[i % nodeColors.length]; + const r = parseInt(color.slice(1, 3), 16); + const g2 = parseInt(color.slice(3, 5), 16); + const b2 = parseInt(color.slice(5, 7), 16); + + // Draw filled bell shape + ctx.save(); + ctx.beginPath(); + ctx.moveTo(padLeft, gy(0)); + for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) { + const resp = calculateBiquadResponse(f, band, sampleRate); + ctx.lineTo(gx(f), gy(resp)); + } + ctx.lineTo(padLeft + w, gy(0)); + ctx.closePath(); + ctx.fillStyle = `rgba(${r},${g2},${b2},0.12)`; + ctx.fill(); + + // Draw bell curve outline + ctx.beginPath(); + let started = false; + for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) { + const resp = calculateBiquadResponse(f, band, sampleRate); + const bx = gx(f); + const by = gy(resp); + if (!started) { + ctx.moveTo(bx, by); + started = true; + } else ctx.lineTo(bx, by); + } + ctx.strokeStyle = `rgba(${r},${g2},${b2},0.5)`; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + }); + + // Draw combined EQ response curve (sum of all bands) + const eqCurve = []; + for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) { + let totalGain = 0; + for (const band of activeBands) { + if (band.enabled) totalGain += calculateBiquadResponse(f, band, sampleRate); + } + eqCurve.push({ freq: f, gain: totalGain }); + } + drawCurve(eqCurve, 'rgba(255,255,255,0.8)', 2); + } + } else { + // AutoEQ / Speaker mode: draw measurement, target, corrected + if (targetData) { + const targetMidAvg = getNormalizationOffset(targetData); + graphShift = dbCenter - targetMidAvg; + } else if (graphMeasurement) { + const measMidAvg = getNormalizationOffset(graphMeasurement); + graphShift = dbCenter - measMidAvg; + } + + // Draw Target curve (shifted) + if (targetData) { + const shiftedTarget = targetData.map((p) => ({ freq: p.freq, gain: p.gain + graphShift })); + drawCurve(shiftedTarget, targetColor, 1.5, true); + } + + // Draw Original measurement (normalized + shifted) + if (graphMeasurement) { + const normOff = targetData + ? getNormalizationOffset(targetData) - getNormalizationOffset(graphMeasurement) + : 0; + const normalized = graphMeasurement.map((p) => ({ freq: p.freq, gain: p.gain + normOff + graphShift })); + drawCurve(normalized, originalColor, 1.5); + } + + // Draw Corrected curve (shifted) + if (autoeqCorrectedCurve) { + const shiftedCorrected = autoeqCorrectedCurve.map((p) => ({ freq: p.freq, gain: p.gain + graphShift })); + drawCurve(shiftedCorrected, correctedColor, 2); } } - }; - /** - * Populate custom presets in the dropdown - */ - const populateCustomPresets = () => { - if (!customPresetsOptgroup) return; + // Speaker EQ: draw bass limit & room limit markers + if (currentMode === 'speaker') { + const bassHz = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40; + const roomHz = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500; - // Clear existing custom presets - customPresetsOptgroup.innerHTML = ''; + // Shaded regions outside EQ range + ctx.fillStyle = 'rgba(34, 211, 238, 0.04)'; + ctx.fillRect(padLeft, padTop, gx(bassHz) - padLeft, h); + ctx.fillStyle = 'rgba(245, 158, 11, 0.04)'; + ctx.fillRect(gx(roomHz), padTop, padLeft + w - gx(roomHz), h); - const customPresets = equalizerSettings.getCustomPresets(); - const presetIds = Object.keys(customPresets); + // Bass limit line (cyan dashed) + ctx.save(); + ctx.beginPath(); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(34, 211, 238, 0.6)'; + ctx.lineWidth = 1.5; + ctx.moveTo(gx(bassHz), padTop); + ctx.lineTo(gx(bassHz), padTop + h); + ctx.stroke(); + ctx.restore(); - if (presetIds.length === 0) { - const emptyOption = document.createElement('option'); - emptyOption.value = ''; - emptyOption.textContent = 'No custom presets saved'; - emptyOption.disabled = true; - customPresetsOptgroup.appendChild(emptyOption); - } else { - presetIds.forEach((presetId) => { - const preset = customPresets[presetId]; - const option = document.createElement('option'); - option.value = presetId; - option.textContent = preset.name; - customPresetsOptgroup.appendChild(option); + // Bass limit label + ctx.save(); + ctx.font = 'bold 9px system-ui'; + ctx.fillStyle = 'rgba(34, 211, 238, 0.7)'; + ctx.textAlign = 'center'; + ctx.fillText(bassHz + ' Hz', gx(bassHz), padTop - 2); + ctx.restore(); + + // Room limit line (amber dashed) + ctx.save(); + ctx.beginPath(); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(245, 158, 11, 0.6)'; + ctx.lineWidth = 1.5; + ctx.moveTo(gx(roomHz), padTop); + ctx.lineTo(gx(roomHz), padTop + h); + ctx.stroke(); + ctx.restore(); + + // Room limit label + ctx.save(); + ctx.font = 'bold 9px system-ui'; + ctx.fillStyle = 'rgba(245, 158, 11, 0.7)'; + ctx.textAlign = 'center'; + ctx.fillText(roomHz + ' Hz', gx(roomHz), padTop - 2); + ctx.restore(); + } + + // Draw interactive nodes + if (activeBands && activeBands.length > 0 && (autoeqCorrectedCurve || isParametricMode)) { + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + activeBands.forEach((band, i) => { + if (!band.enabled) return; + const x = gx(band.freq); + // In parametric mode: node Y = band's individual response at its freq (basically its gain) + // In AutoEQ mode: node Y = corrected curve value at band freq (shifted) + let nodeGain; + if (isParametricMode) { + // Sum all bands' response at this frequency + let totalGain = 0; + for (const b of activeBands) { + if (b.enabled) totalGain += calculateBiquadResponse(band.freq, b, sampleRate); + } + nodeGain = totalGain; + } else { + nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift; + } + const y = gy(nodeGain); + + // Draw node circle with unique color per band + const nodeColors = [ + '#f472b6', + '#fb923c', + '#facc15', + '#4ade80', + '#22d3ee', + '#818cf8', + '#c084fc', + '#f87171', + '#34d399', + '#60a5fa', + '#a78bfa', + '#fb7185', + '#fbbf24', + '#2dd4bf', + '#38bdf8', + '#a3e635', + ]; + const nodeColor = nodeColors[i % nodeColors.length]; + const isHovered = i === hoveredNode; + const isDragged = i === draggedNode; + const radius = isDragged ? 9 : isHovered ? 7 : 5; + + // Glow effect on hover/drag + if (isHovered || isDragged) { + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, radius + 4, 0, Math.PI * 2); + ctx.fillStyle = nodeColor.replace(')', ', 0.25)').replace('rgb', 'rgba').replace('#', ''); + // Use hex to rgba + const r2 = parseInt(nodeColor.slice(1, 3), 16); + const g2 = parseInt(nodeColor.slice(3, 5), 16); + const b2 = parseInt(nodeColor.slice(5, 7), 16); + ctx.fillStyle = `rgba(${r2},${g2},${b2},0.25)`; + ctx.fill(); + ctx.restore(); + } + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = isDragged ? '#fff' : nodeColor; + ctx.fill(); + ctx.strokeStyle = isDragged ? nodeColor : 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Show tooltip on drag + if (isDragged) { + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.8)'; + const txt = `${Math.round(band.freq)} Hz ${band.gain > 0 ? '+' : ''}${band.gain.toFixed(1)} dB Q${band.q.toFixed(2)}`; + ctx.font = 'bold 11px system-ui, sans-serif'; + const tw = ctx.measureText(txt).width + 12; + const tx = Math.min(x - tw / 2, rect.width - tw - 5); + const ty = y - 28; + ctx.fillRect(tx, ty, tw, 20); + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(txt, tx + tw / 2, ty + 10); + ctx.restore(); + } }); } }; /** - * Check if a preset ID is a custom preset + * Compute corrected curve from measurement + bands */ - const isCustomPreset = (presetId) => { - return presetId && presetId.startsWith('custom_'); + const computeCorrectedCurve = () => { + let measurement, bands, tId, tList; + if (currentMode === 'speaker') { + const sCh = speakerChannels[speakerActiveChannel]; + measurement = sCh?.measurement; + bands = sCh?.bands; + tId = sCh?.targetId || 'harman_room'; + tList = SPEAKER_TARGETS; + } else { + measurement = autoeqSelectedMeasurement; + bands = autoeqCurrentBands; + tId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018'; + tList = TARGETS; + } + + if (!measurement || !bands) { + autoeqCorrectedCurve = null; + return; + } + const targetEntry = tList.find((t) => t.id === tId); + const targetData = targetEntry?.data; + const normOff = targetData ? getNormalizationOffset(targetData) - getNormalizationOffset(measurement) : 0; + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + + autoeqCorrectedCurve = measurement.map((p) => { + let correction = 0; + for (const band of bands) { + if (band.enabled) correction += calculateBiquadResponse(p.freq, band, sampleRate); + } + return { freq: p.freq, gain: p.gain + normOff + correction }; + }); }; /** - * Update delete button visibility based on selected preset + * Get canvas coordinates from mouse event */ - const updateDeleteButtonVisibility = () => { - if (!deleteCustomPresetBtn || !eqPresetSelect) return; - const isCustom = isCustomPreset(eqPresetSelect.value); - deleteCustomPresetBtn.style.display = isCustom ? 'flex' : 'none'; + const getCanvasCoords = (e) => { + const rect = autoeqCanvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; /** - * Draw smooth EQ response curve on canvas + * Find closest node to coordinates */ - const drawEQCurve = () => { - const canvas = document.getElementById('eq-response-canvas'); + const findClosestNode = (mx, my, threshold = 15) => { + const activeBands = getActiveBands(); + if (!activeBands || !autoeqCanvas) return -1; + const isParam = currentMode === 'parametric'; + if (!isParam && !autoeqCorrectedCurve) return -1; + + const rect = autoeqCanvas.getBoundingClientRect(); + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; + const w = rect.width - padLeft - padRight; + const h = rect.height - padTop - padBottom; + + const dbCenter = isParam ? 0 : 75; + const dbHalfRange = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ; + const dbMin = dbCenter - dbHalfRange; + const dbMax = dbCenter + dbHalfRange; + + let graphShift = 0; + if (!isParam) { + let tId, tList, meas; + if (currentMode === 'speaker') { + const sCh = speakerChannels[speakerActiveChannel]; + tId = sCh?.targetId || 'harman_room'; + tList = SPEAKER_TARGETS; + meas = sCh?.measurement; + } else { + tId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018'; + tList = TARGETS; + meas = autoeqSelectedMeasurement; + } + const targetEntry = tList.find((t) => t.id === tId); + const targetData = targetEntry?.data; + if (targetData) graphShift = 75 - getNormalizationOffset(targetData); + else if (meas) graphShift = 75 - getNormalizationOffset(meas); + } + + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + let closest = -1, + closestDist = Infinity; + activeBands.forEach((band, i) => { + if (!band.enabled) return; + const x = padLeft + freqToX(band.freq, w); + let nodeGain; + if (isParam) { + nodeGain = 0; + for (const b of activeBands) { + if (b.enabled) nodeGain += calculateBiquadResponse(band.freq, b, sampleRate); + } + } else { + nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift; + } + const y = padTop + dbToY(nodeGain, h, dbMin, dbMax); + const dist = Math.sqrt((mx - x) ** 2 + (my - y) ** 2); + if (dist < threshold && dist < closestDist) { + closest = i; + closestDist = dist; + } + }); + return closest; + }; + + /** + * Auto preamp compensation state + */ + let autoPreampEnabled = false; + const autoPreampToggle = document.getElementById('autoeq-auto-preamp-toggle'); + + /** + * Apply current bands to audio engine + */ + const applyBandsToAudio = (bands) => { + if (bands && bands.length > 0) { + // Pass skipPreamp=true when auto preamp is off so the engine doesn't override manual preamp + audioContextManager.applyAutoEQBands(bands, !autoPreampEnabled); + currentPreamp = equalizerSettings.getPreamp(); + if (eqPreampSlider) eqPreampSlider.value = currentPreamp; + if (autoeqPreampValue) autoeqPreampValue.textContent = `${currentPreamp} dB`; + } + }; + + // ======================================== + // Interactive Graph Mouse/Touch Handlers + // ======================================== + if (autoeqCanvas) { + autoeqCanvas.addEventListener('mousedown', (e) => { + const coords = getCanvasCoords(e); + const nodeIdx = findClosestNode(coords.x, coords.y, 18); + if (nodeIdx >= 0) { + draggedNode = nodeIdx; + autoeqCanvas.style.cursor = 'grabbing'; + e.preventDefault(); + } + }); + + autoeqCanvas.addEventListener('mousemove', (e) => { + const coords = getCanvasCoords(e); + const bands = getActiveBands(); + if (draggedNode !== null && bands) { + const rect = autoeqCanvas.getBoundingClientRect(); + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; + const w = rect.width - padLeft - padRight; + const h = rect.height - padTop - padBottom; + + const isParam = currentMode === 'parametric'; + const dbCenter = isParam ? 0 : 75; + const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ; + const dbMin = dbCenter - dbHalf; + const dbMax = dbCenter + dbHalf; + + const freq = xToFreq(coords.x - padLeft, w); + bands[draggedNode].freq = Math.max(20, Math.min(20000, freq)); + + if (isParam) { + const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax); + bands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10)); + } else { + const corrGain = interpolate(bands[draggedNode].freq, autoeqCorrectedCurve || []); + const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax); + const gainDelta = newDb - corrGain; + bands[draggedNode].gain = Math.max(-30, Math.min(30, bands[draggedNode].gain + gainDelta * 0.3)); + } + + if (!graphAnimFrame) { + graphAnimFrame = requestAnimationFrame(() => { + computeCorrectedCurve(); + applyBandsToAudio(bands); + drawAutoEQGraph(); + renderBandControls(bands); + graphAnimFrame = null; + }); + } + } else { + const padLeft = 40; + if (coords.x <= padLeft + 10) { + autoeqCanvas.style.cursor = 'ns-resize'; + if (hoveredNode !== null) { + hoveredNode = null; + drawAutoEQGraph(); + } + } else { + const newHovered = findClosestNode(coords.x, coords.y, 18); + if (newHovered !== hoveredNode) { + hoveredNode = newHovered; + autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair'; + drawAutoEQGraph(); + } + } + } + }); + + autoeqCanvas.addEventListener('mouseup', () => { + draggedNode = null; + autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair'; + }); + + autoeqCanvas.addEventListener('mouseleave', () => { + draggedNode = null; + hoveredNode = null; + autoeqCanvas.style.cursor = 'crosshair'; + drawAutoEQGraph(); + }); + + autoeqCanvas.addEventListener('dblclick', (e) => { + e.preventDefault(); + const coords = getCanvasCoords(e); + const isParam = currentMode === 'parametric'; + + // getActiveBands() returns null in autoeq mode before first run — init to empty array + let bands = getActiveBands(); + if (!bands) { + if (currentMode === 'autoeq') { + autoeqCurrentBands = []; + bands = autoeqCurrentBands; + } else return; + } + + // findClosestNode needs autoeqCorrectedCurve in non-parametric modes. + // Fall back to frequency-only (X-axis) matching when corrected curve is absent. + let nodeIdx = findClosestNode(coords.x, coords.y, 18); + if (nodeIdx < 0 && !isParam && !autoeqCorrectedCurve && bands.length > 0) { + const rect2 = autoeqCanvas.getBoundingClientRect(); + const w2 = rect2.width - 40 - 10; + let best = Infinity; + bands.forEach((band, i) => { + const dx = Math.abs(coords.x - (40 + freqToX(band.freq, w2))); + if (dx < 18 && dx < best) { + best = dx; + nodeIdx = i; + } + }); + } + + if (nodeIdx >= 0) { + bands.splice(nodeIdx, 1); + bands.forEach((b, i) => { + b.id = i; + }); + draggedNode = null; + hoveredNode = null; + } else { + if (bands.length >= 32) return; + const rect = autoeqCanvas.getBoundingClientRect(); + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; + const w = rect.width - padLeft - padRight; + const h = rect.height - padTop - padBottom; + const dbCenter = isParam ? 0 : 75; + const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ; + const dbMin = dbCenter - dbHalf; + const dbMax = dbCenter + dbHalf; + const freq = Math.max(20, Math.min(20000, Math.round(xToFreq(coords.x - padLeft, w)))); + const gain = Math.max( + -30, + Math.min(30, Math.round((yToDb(coords.y - padTop, h, dbMin, dbMax) - dbCenter) * 10) / 10) + ); + bands.push({ id: bands.length, type: 'peaking', freq, gain, q: 1.0, enabled: true }); + } + + setActiveBands(bands); + computeCorrectedCurve(); + applyBandsToAudio(bands); + renderBandControls(bands); + drawAutoEQGraph(); + }); + + autoeqCanvas.addEventListener( + 'wheel', + (e) => { + const coords = getCanvasCoords(e); + const padLeft = 40; + + // Scroll on Y axis area (left edge) = dB zoom + if (coords.x <= padLeft + 10) { + e.preventDefault(); + const zoomStep = e.deltaY > 0 ? 2 : -2; // scroll down = zoom out (wider range), scroll up = zoom in + if (currentMode === 'parametric') { + graphDbHalfParametric = Math.max(5, Math.min(60, graphDbHalfParametric + zoomStep)); + } else { + graphDbHalfAutoEQ = Math.max(5, Math.min(60, graphDbHalfAutoEQ + zoomStep)); + } + drawAutoEQGraph(); + return; + } + + // Scroll on a node = Q adjust + const wBands = getActiveBands(); + if (hoveredNode >= 0 && wBands && wBands[hoveredNode]) { + e.preventDefault(); + const band = wBands[hoveredNode]; + const delta = e.deltaY > 0 ? -0.15 : 0.15; + band.q = Math.max(0.1, Math.min(10, (band.q || 1) + delta)); + computeCorrectedCurve(); + applyBandsToAudio(wBands); + drawAutoEQGraph(); + renderBandControls(wBands); + } + }, + { passive: false } + ); + + // Touch support + let touchNodeIdx = -1; + autoeqCanvas.addEventListener( + 'touchstart', + (e) => { + const touch = e.touches[0]; + const coords = { + x: touch.clientX - autoeqCanvas.getBoundingClientRect().left, + y: touch.clientY - autoeqCanvas.getBoundingClientRect().top, + }; + touchNodeIdx = findClosestNode(coords.x, coords.y, 25); + if (touchNodeIdx >= 0) { + draggedNode = touchNodeIdx; + e.preventDefault(); + } + }, + { passive: false } + ); + + autoeqCanvas.addEventListener( + 'touchmove', + (e) => { + const tBands = getActiveBands(); + if (draggedNode !== null && tBands) { + const touch = e.touches[0]; + const rect = autoeqCanvas.getBoundingClientRect(); + const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; + const w = rect.width - padLeft - padRight; + const h = rect.height - padTop - padBottom; + + const freq = xToFreq(coords.x - padLeft, w); + tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq)); + + if (currentMode === 'parametric') { + const newGain = yToDb(coords.y - padTop, h, -graphDbHalfParametric, graphDbHalfParametric); + tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10)); + } + + computeCorrectedCurve(); + applyBandsToAudio(tBands); + if (!graphAnimFrame) { + graphAnimFrame = requestAnimationFrame(() => { + drawAutoEQGraph(); + renderBandControls(tBands); + graphAnimFrame = null; + }); + } + e.preventDefault(); + } + }, + { passive: false } + ); + + autoeqCanvas.addEventListener('touchend', () => { + draggedNode = null; + touchNodeIdx = -1; + }); + + // Resize observer for graph + if (autoeqGraphWrapper) { + const ro = new ResizeObserver(() => { + drawAutoEQGraph(); + }); + ro.observe(autoeqGraphWrapper); + } + } + + // ======================================== + // Per-Band Parametric EQ Controls + // ======================================== + const renderBandControls = (bands) => { + if (!autoeqBandsList) return; + autoeqBandsList.innerHTML = ''; + if (!bands || bands.length === 0) return; + + bands.forEach((band, i) => { + const control = document.createElement('div'); + control.className = 'autoeq-band-control'; + control.dataset.band = i; + const currentType = band.type || 'peaking'; + control.innerHTML = ` +

+ ${i + 1} + +
+ Freq + ${formatFreq(band.freq)} Hz +
+
+ Gain + ${band.gain > 0 ? '+' : ''}${band.gain.toFixed(1)} dB +
+
+ Q + ${band.q.toFixed(2)} +
+
+
+ + + +
+ `; + autoeqBandsList.appendChild(control); + + // Attach slider event listeners + const freqSlider = control.querySelector('.autoeq-freq-slider'); + const gainSlider = control.querySelector('.autoeq-gain-slider'); + const qSlider = control.querySelector('.autoeq-q-slider'); + const freqVal = control.querySelector('.autoeq-freq-val'); + const gainVal = control.querySelector('.autoeq-gain-val'); + const qVal = control.querySelector('.autoeq-q-val'); + + freqSlider.addEventListener('input', () => { + const bands = getActiveBands(); + if (!bands || !bands[i]) return; + bands[i].freq = parseFloat(freqSlider.value); + freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`; + computeCorrectedCurve(); + applyBandsToAudio(bands); + drawAutoEQGraph(); + }); + + gainSlider.addEventListener('input', () => { + const bands = getActiveBands(); + if (!bands || !bands[i]) return; + bands[i].gain = parseFloat(gainSlider.value); + gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`; + computeCorrectedCurve(); + applyBandsToAudio(bands); + drawAutoEQGraph(); + }); + + qSlider.addEventListener('input', () => { + const bands = getActiveBands(); + if (!bands || !bands[i]) return; + bands[i].q = parseFloat(qSlider.value); + qVal.textContent = bands[i].q.toFixed(2); + computeCorrectedCurve(); + applyBandsToAudio(bands); + drawAutoEQGraph(); + }); + + const typeSelect = control.querySelector('.autoeq-type-select'); + typeSelect.addEventListener('change', () => { + const bands = getActiveBands(); + if (!bands || !bands[i]) return; + bands[i].type = typeSelect.value; + computeCorrectedCurve(); + applyBandsToAudio(bands); + drawAutoEQGraph(); + }); + }); + }; + + // ======================================== + // EQ Toggle + Container Visibility + // ======================================== + /** + * Ensure parametric bands exist - creates default 10 log-spaced bands if none + */ + const ensureParametricBands = () => { + if (!parametricBands || parametricBands.length === 0) { + const defaultBands = []; + for (let i = 0; i < 10; i++) { + const freq = 20 * Math.pow(20000 / 20, i / 9); + defaultBands.push({ id: i, type: 'peaking', freq: Math.round(freq), gain: 0, q: 1.0, enabled: true }); + } + parametricBands = defaultBands; + applyBandsToAudio(parametricBands); + } + }; + + const updateEQContainerVisibility = (enabled) => { + if (eqContainer) { + eqContainer.style.display = enabled ? 'flex' : 'none'; + if (enabled) { + // Ensure bands exist when EQ is enabled (fixes parametric mode without AutoEQ) + if (currentMode === 'parametric') { + ensureParametricBands(); + applyBandsToAudio(parametricBands); + renderBandControls(parametricBands); + } + requestAnimationFrame(drawAutoEQGraph); + } + } + }; + + // ======================================== + // Collapsible Sections + // ======================================== + // Saved Profiles collapse + if (autoeqSavedCollapse) { + const savedGrid = document.getElementById('autoeq-saved-grid'); + autoeqSavedCollapse.addEventListener('click', (e) => { + e.stopPropagation(); + autoeqSavedCollapse.classList.toggle('collapsed'); + if (savedGrid) + savedGrid.style.display = autoeqSavedCollapse.classList.contains('collapsed') ? 'none' : 'flex'; + }); + } + + // Parametric EQ Filters collapse + if (autoeqFiltersToggle) { + autoeqFiltersToggle.addEventListener('click', () => { + if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.toggle('collapsed'); + if (autoeqFiltersContent) + autoeqFiltersContent.style.display = autoeqFiltersContent.style.display === 'none' ? 'flex' : 'none'; + }); + } + + // ======================================== + // Set Status Message + // ======================================== + const setAutoEQStatus = (msg, type = '') => { + if (!autoeqStatus) return; + autoeqStatus.textContent = msg; + autoeqStatus.className = 'autoeq-status' + (type ? ' ' + type : ''); + }; + + // ======================================== + // Downsample curve for profile storage + // ======================================== + const downsampleCurve = (data, maxPoints = 80) => { + if (!data || data.length <= maxPoints) return data ? [...data] : []; + const result = []; + const step = data.length / maxPoints; + for (let i = 0; i < maxPoints; i++) { + result.push({ ...data[Math.floor(i * step)] }); + } + return result; + }; + + // ======================================== + // Mini-Graph Renderer for Profile Cards + // ======================================== + const drawMiniGraph = (canvas, measurementData, targetData, correctedData) => { if (!canvas) return; - const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); + if (rect.width === 0) { + // Canvas not laid out yet — retry when it becomes visible + const obs = new IntersectionObserver((entries, observer) => { + if (entries[0].isIntersecting) { + observer.disconnect(); + drawMiniGraph(canvas, measurementData, targetData, correctedData); + } + }); + obs.observe(canvas); + return; + } - // Skip if canvas has no size (not visible yet) - if (rect.width === 0 || rect.height === 0) return; - - // Set canvas size accounting for DPR canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; + canvas.height = (rect.height || 60) * dpr; ctx.scale(dpr, dpr); + const w = rect.width; + const h = rect.height || 60; - const width = rect.width; - const height = rect.height; + ctx.clearRect(0, 0, w, h); - // Clear canvas - ctx.clearRect(0, 0, width, height); + const drawMiniFill = (data, colors) => { + if (!data || data.length < 2) return; + const allGains = data.map((p) => p.gain); + const dMin = Math.min(...allGains) - 2; + const dMax = Math.max(...allGains) + 2; + const dRange = dMax - dMin || 1; - // Get all current gain values - const eqBands = eqBandsContainer?.querySelectorAll('.eq-band'); - if (!eqBands || eqBands.length === 0) return; + const gradient = ctx.createLinearGradient(0, 0, w, 0); + colors.forEach((c, i) => gradient.addColorStop(i / (colors.length - 1), c)); - // Get the actual highlight color from CSS - const tempEl = document.createElement('div'); - tempEl.style.color = 'rgb(var(--highlight-rgb))'; - document.body.appendChild(tempEl); - const highlightColor = getComputedStyle(tempEl).color; - document.body.removeChild(tempEl); + ctx.beginPath(); + ctx.moveTo(0, h); + for (let i = 0; i < data.length; i++) { + const x = freqToX(data[i].freq, w); + const y = h - ((data[i].gain - dMin) / dRange) * h * 0.8 - h * 0.1; + if (i === 0) ctx.lineTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.globalAlpha = 0.4; + ctx.fill(); + ctx.globalAlpha = 1; - const gains = []; - const positions = []; - const range = currentRange; - const rangeTotal = range.max - range.min; - const canvasRect = canvas.getBoundingClientRect(); + // Draw line + ctx.beginPath(); + ctx.strokeStyle = gradient; + ctx.lineWidth = 1.5; + for (let i = 0; i < data.length; i++) { + const x = freqToX(data[i].freq, w); + const y = h - ((data[i].gain - dMin) / dRange) * h * 0.8 - h * 0.1; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }; - eqBands.forEach((bandEl) => { - const slider = bandEl.querySelector('.eq-slider'); - const gain = slider ? parseFloat(slider.value) : 0; - gains.push(gain); + if (measurementData) drawMiniFill(measurementData, ['#3b82f6', '#06b6d4', '#8b5cf6']); + if (targetData) drawMiniFill(targetData, ['#f472b6', '#a855f7', '#6366f1']); + if (correctedData) drawMiniFill(correctedData, ['#22c55e', '#06b6d4', '#3b82f6']); + }; - // Get actual center position of the band element relative to canvas - const bandRect = bandEl.getBoundingClientRect(); - const x = bandRect.left + bandRect.width / 2 - canvasRect.left; - positions.push(x); + const BAND_PREVIEW_COLORS = [ + '#f472b6', + '#fb923c', + '#facc15', + '#4ade80', + '#22d3ee', + '#818cf8', + '#c084fc', + '#f87171', + '#34d399', + '#60a5fa', + ]; + + const drawBandsPreview = (canvas, bands, sampleRate) => { + if (!canvas || !bands || bands.length === 0) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0) { + const obs = new IntersectionObserver((entries, observer) => { + if (entries[0].isIntersecting) { + observer.disconnect(); + drawBandsPreview(canvas, bands, sampleRate); + } + }); + obs.observe(canvas); + return; + } + const sr = sampleRate || 48000; + const ph = rect.height || 100; + canvas.width = rect.width * dpr; + canvas.height = ph * dpr; + ctx.scale(dpr, dpr); + const pw = rect.width; + ctx.clearRect(0, 0, pw, ph); + const mid = ph / 2; + const dbRange = 12; // -12dB to +12dB + + // Draw each band as a filled blob + bands.forEach((band, bi) => { + if (!band.enabled || Math.abs(band.gain) < 0.1) return; + const color = BAND_PREVIEW_COLORS[bi % BAND_PREVIEW_COLORS.length]; + const pts = []; + for (let f = 20; f <= 20000; f *= 1.04) { + const resp = calculateBiquadResponse(f, band, sr); + pts.push({ + x: freqToX(f, pw), + y: mid - (Math.max(-dbRange, Math.min(dbRange, resp)) / dbRange) * mid * 0.9, + }); + } + if (!pts.length) return; + + ctx.beginPath(); + ctx.moveTo(pts[0].x, mid); + pts.forEach((p) => ctx.lineTo(p.x, p.y)); + ctx.lineTo(pts[pts.length - 1].x, mid); + ctx.closePath(); + const grad = ctx.createLinearGradient(0, 0, pw, 0); + grad.addColorStop(0, color + '18'); + grad.addColorStop(0.5, color + '55'); + grad.addColorStop(1, color + '18'); + ctx.fillStyle = grad; + ctx.fill(); + + ctx.beginPath(); + pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.85; + ctx.stroke(); + ctx.globalAlpha = 1; }); - // Calculate y positions - account for slider thumb size (18px) - // The track is 120px, but thumb center moves within (120 - 18) = 102px range - const trackHeight = height; - const thumbSize = 18; - const usableTrack = trackHeight - thumbSize; - const trackOffset = thumbSize / 2; - - const getY = (gain) => { - const normalized = (gain - range.min) / rangeTotal; - // Invert because canvas Y=0 is at top, slider max is at top - return trackOffset + (1 - normalized) * usableTrack; - }; - - // Create points array - const points = gains.map((gain, i) => ({ - x: positions[i], - y: getY(gain), - })); - - if (points.length < 2) return; - - // Parse RGB values from color string - const rgbMatch = highlightColor.match(/\d+/g); - const r = rgbMatch ? parseInt(rgbMatch[0]) : 128; - const g = rgbMatch ? parseInt(rgbMatch[1]) : 128; - const b = rgbMatch ? parseInt(rgbMatch[2]) : 128; - - // Calculate control points for smooth curve - const getControlPoints = (i) => { - const p0 = points[i === 0 ? i : i - 1]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[i + 2] || p2; - - const cp1x = p1.x + (p2.x - p0.x) / 6; - const cp1y = p1.y + (p2.y - p0.y) / 6; - const cp2x = p2.x - (p3.x - p1.x) / 6; - const cp2y = p2.y - (p3.y - p1.y) / 6; - - return { cp1x, cp1y, cp2x, cp2y }; - }; - - // Draw filled area from curve to bottom - const gradient = ctx.createLinearGradient(0, 0, 0, height); - gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.3)`); - gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.05)`); - + // Combined curve on top ctx.beginPath(); - ctx.moveTo(points[0].x, height); - ctx.lineTo(points[0].x, points[0].y); - - for (let i = 0; i < points.length - 1; i++) { - const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i); - const p2 = points[i + 1]; - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + let first = true; + for (let f = 20; f <= 20000; f *= 1.04) { + let total = 0; + for (const b of bands) { + if (b.enabled) total += calculateBiquadResponse(f, b, sr); + } + const x = freqToX(f, pw); + const y = mid - (Math.max(-dbRange, Math.min(dbRange, total)) / dbRange) * mid * 0.9; + first ? (ctx.moveTo(x, y), (first = false)) : ctx.lineTo(x, y); } - - ctx.lineTo(points[points.length - 1].x, height); - ctx.closePath(); - ctx.fillStyle = gradient; - ctx.fill(); - - // Draw the curve line - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - - for (let i = 0; i < points.length - 1; i++) { - const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i); - const p2 = points[i + 1]; - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); - } - - ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`; + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 2; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; ctx.stroke(); + }; - // Draw dots at each band point - points.forEach((point) => { - ctx.beginPath(); - ctx.arc(point.x, point.y, 4, 0, Math.PI * 2); - ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; - ctx.fill(); + // ======================================== + // Saved Profiles Rendering + // ======================================== + const renderSavedProfiles = () => { + if (!autoeqSavedGrid) return; + const profiles = equalizerSettings.getAutoEQProfiles(); + const activeId = equalizerSettings.getActiveAutoEQProfile(); + const keys = Object.keys(profiles); - // Add white center to dots for visibility - ctx.beginPath(); - ctx.arc(point.x, point.y, 2, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fill(); + if (autoeqSavedCount) autoeqSavedCount.textContent = keys.length; + autoeqSavedGrid.innerHTML = ''; + + if (keys.length === 0) return; + + keys.forEach((id) => { + const profile = profiles[id]; + const card = document.createElement('div'); + card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : ''); + card.dataset.profileId = id; + + const preview = document.createElement('canvas'); + preview.className = 'autoeq-profile-preview'; + card.appendChild(preview); + + const info = document.createElement('div'); + info.className = 'autoeq-profile-info'; + info.innerHTML = ` + + ${profile.name || 'Unnamed'} + ${profile.bandCount || '?'} bands · ${profile.targetLabel || ''} + `; + card.appendChild(info); + + const delBtn = document.createElement('button'); + delBtn.className = 'autoeq-profile-delete'; + delBtn.innerHTML = '🗑'; + delBtn.title = 'Delete profile'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + equalizerSettings.deleteAutoEQProfile(id); + renderSavedProfiles(); + }); + card.appendChild(delBtn); + + // Click to load profile + card.addEventListener('click', () => { + loadAutoEQProfile(id); + }); + + autoeqSavedGrid.appendChild(card); + + // Draw mini preview using filter bands + requestAnimationFrame(() => { + drawBandsPreview(preview, profile.bands, profile.sampleRate); + }); + }); + }; + + // ======================================== + // Profile Save/Load + // ======================================== + const saveAutoEQProfile = (name) => { + if (!autoeqCurrentBands || !autoeqSelectedMeasurement) return; + + const targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018'; + const targetEntry = TARGETS.find((t) => t.id === targetId); + + const profile = { + id: 'autoeq_' + Date.now(), + name: name || (autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom'), + headphoneName: autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom', + headphoneType: autoeqSelectedEntry ? autoeqSelectedEntry.type : 'over-ear', + targetId, + targetLabel: targetEntry ? targetEntry.label : targetId, + bandCount: + (autoeqBandCount && autoeqBandCount.value ? parseInt(autoeqBandCount.value, 10) : null) || + autoeqCurrentBands.length || + 10, + maxFreq: autoeqMaxFreq ? parseInt(autoeqMaxFreq.value, 10) : 16000, + sampleRate: autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000, + bands: autoeqCurrentBands.map((b) => ({ ...b })), + gains: audioContextManager.getGains ? audioContextManager.getGains() : [], + preamp: equalizerSettings.getPreamp(), + measurementData: downsampleCurve(autoeqSelectedMeasurement), + targetData: downsampleCurve(targetEntry?.data), + correctedData: downsampleCurve(autoeqCorrectedCurve), + createdAt: Date.now(), + }; + + const id = equalizerSettings.saveAutoEQProfile(profile); + equalizerSettings.setActiveAutoEQProfile(id); + renderSavedProfiles(); + setAutoEQStatus(`Profile "${name}" saved`, 'success'); + }; + + const loadAutoEQProfile = (profileId) => { + const profiles = equalizerSettings.getAutoEQProfiles(); + const profile = profiles[profileId]; + if (!profile) return; + + autoeqCurrentBands = profile.bands.map((b) => ({ ...b })); + autoeqCorrectedCurve = profile.correctedData ? [...profile.correctedData] : null; + autoeqSelectedMeasurement = profile.measurementData ? [...profile.measurementData] : null; + autoeqSelectedEntry = { name: profile.headphoneName, type: profile.headphoneType }; + + // Update headphone select dropdown + if (autoeqHeadphoneSelect) { + let opt = autoeqHeadphoneSelect.querySelector(`option[value="${profile.headphoneName}"]`); + if (!opt) { + opt = document.createElement('option'); + opt.value = profile.headphoneName; + opt.textContent = profile.headphoneName.replace(/\s*\([^)]*\)\s*$/, ''); + autoeqHeadphoneSelect.appendChild(opt); + } + autoeqHeadphoneSelect.value = profile.headphoneName; + } + + // Update UI selects + if (autoeqTargetSelect) autoeqTargetSelect.value = profile.targetId || 'harman_oe_2018'; + setAutoeqBandCount(profile.bandCount, profile.bands); + if (autoeqMaxFreq) autoeqMaxFreq.value = profile.maxFreq || 16000; + if (autoeqSampleRate) autoeqSampleRate.value = profile.sampleRate || 48000; + + // Apply to audio + applyBandsToAudio(autoeqCurrentBands); + + equalizerSettings.setActiveAutoEQProfile(profileId); + renderSavedProfiles(); + renderBandControls(autoeqCurrentBands); + drawAutoEQGraph(); + setAutoEQStatus(`Loaded "${profile.name}"`, 'success'); + }; + + // Save button + if (autoeqSaveBtn) { + autoeqSaveBtn.addEventListener('click', () => { + const name = autoeqProfileNameInput ? autoeqProfileNameInput.value.trim() : ''; + if (!name) { + setAutoEQStatus('Enter a profile name', 'error'); + return; + } + saveAutoEQProfile(name); + if (autoeqProfileNameInput) autoeqProfileNameInput.value = ''; + }); + } + + // ======================================== + + // ======================================== + // Database Browser + // ======================================== + /** + * Load a headphone measurement entry + */ + const loadHeadphoneEntry = async (entry) => { + setAutoEQStatus('Loading measurement...', ''); + try { + const data = await fetchHeadphoneData(entry); + autoeqSelectedMeasurement = data; + autoeqSelectedEntry = entry; + + if (autoeqHeadphoneSelect) { + let opt = autoeqHeadphoneSelect.querySelector(`option[value="${entry.name}"]`); + if (!opt) { + opt = document.createElement('option'); + opt.value = entry.name; + opt.textContent = entry.name; + autoeqHeadphoneSelect.appendChild(opt); + } + autoeqHeadphoneSelect.value = entry.name; + } + + if (autoeqTargetSelect && entry.type === 'in-ear') { + autoeqTargetSelect.value = 'harman_ie_2019'; + } + + if (autoeqRunBtn) autoeqRunBtn.disabled = false; + drawAutoEQGraph(); + setAutoEQStatus(`Loaded ${data.length} points for ${entry.name}`, 'success'); + + // Persist for reload + equalizerSettings.setLastHeadphone(entry, data); + } catch (err) { + setAutoEQStatus('Failed: ' + err.message, 'error'); + } + }; + + /** + * Render database list with expandable headphone groups + */ + const renderDatabaseResults = (entries, append = false) => { + if (!autoeqDatabaseList) return; + if (!append) autoeqDatabaseList.innerHTML = ''; + + if (entries.length === 0 && !append) { + autoeqDatabaseList.innerHTML = + '
No results found
'; + return; + } + + // Group by base model name (strip source suffix like "(crinacle)") + const modelMap = new Map(); + entries.forEach((entry) => { + const baseName = entry.name.replace(/\s*\([^)]*\)\s*$/, '').trim() || entry.name; + if (!modelMap.has(baseName)) { + modelMap.set(baseName, []); + } + modelMap.get(baseName).push(entry); + }); + + modelMap.forEach((variants, name) => { + const wrapper = document.createElement('div'); + const rawFirstChar = name[0]?.toUpperCase() || '#'; + const firstLetter = /^[A-Z]$/.test(rawFirstChar) ? rawFirstChar : '#'; + wrapper.dataset.letter = firstLetter; + + const item = document.createElement('div'); + item.className = 'autoeq-db-item'; + item.dataset.name = name; + + item.innerHTML = ` + +
+ ${name} + ${variants.length} profile${variants.length > 1 ? 's' : ''} +
+ + `; + + wrapper.appendChild(item); + + // Sub-list for multiple profiles + if (variants.length > 1) { + const subList = document.createElement('div'); + subList.className = 'autoeq-db-sub-list'; + + variants.forEach((entry) => { + const subItem = document.createElement('div'); + subItem.className = 'autoeq-db-sub-item'; + // Extract source from parentheses + const sourceMatch = entry.name.match(/\(([^)]+)\)\s*$/); + const source = sourceMatch ? sourceMatch[1] : entry.type; + subItem.innerHTML = `${entry.name}${source}`; + subItem.addEventListener('click', (e) => { + e.stopPropagation(); + loadHeadphoneEntry(entry); + }); + subList.appendChild(subItem); + }); + + wrapper.appendChild(subList); + + item.addEventListener('click', () => { + item.classList.toggle('expanded'); + subList.classList.toggle('visible'); + }); + } else { + // Single profile - load directly + item.addEventListener('click', () => loadHeadphoneEntry(variants[0])); + } + + autoeqDatabaseList.appendChild(wrapper); }); }; /** - * Initialize band slider event listeners + * Render the A-Z alphabet index */ - const initializeBandSliders = () => { - const eqBands = eqBandsContainer?.querySelectorAll('.eq-band'); - if (!eqBands || eqBands.length === 0) return; + const renderAlphaIndex = () => { + const alphaContainer = document.getElementById('autoeq-alpha-index'); + if (!alphaContainer) return; + alphaContainer.innerHTML = ''; - const savedGains = equalizerSettings.getGains(currentBandCount); - - // FL Studio-style absolute position drag state - let isDragging = false; - - eqBands.forEach((bandEl) => { - const bandIndex = parseInt(bandEl.dataset.band, 10); - const slider = bandEl.querySelector('.eq-slider'); - - if (slider && !isNaN(bandIndex)) { - // Set initial value from saved settings - const initialGain = savedGains[bandIndex] ?? 0; - slider.value = initialGain; - updateBandValueDisplay(bandEl, initialGain); - - // Handle slider input - slider.addEventListener('input', (e) => { - const gain = parseFloat(e.target.value); - audioContextManager.setBandGain(bandIndex, gain); - updateBandValueDisplay(bandEl, gain); - drawEQCurve(); - - // When manually adjusting, check if we should clear preset - if (eqPresetSelect && eqPresetSelect.value !== 'flat') { - const currentGains = audioContextManager.getGains(); - const builtInPresets = EQ_PRESETS; - const currentPreset = builtInPresets[eqPresetSelect.value]; - if (currentPreset) { - const matches = currentPreset.gains.every((g, i) => Math.abs(g - currentGains[i]) < 0.01); - if (!matches) { - // User has deviated from preset - } - } - } + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split(''); + letters.forEach((letter) => { + const btn = document.createElement('button'); + btn.textContent = letter; + btn.addEventListener('click', () => { + // Find the index of the first entry starting with this letter + const targetIdx = _dbFilteredEntries.findIndex((e) => { + const first = e.name[0].toUpperCase(); + return letter === '#' ? !/[A-Z]/.test(first) : first === letter; }); - // Double-click to reset individual band to 0 - slider.addEventListener('dblclick', () => { - slider.value = 0; - audioContextManager.setBandGain(bandIndex, 0); - updateBandValueDisplay(bandEl, 0); - drawEQCurve(); - }); + if (targetIdx < 0) return; // No entries for this letter - // FL Studio-style absolute drag: mousedown starts drag mode - bandEl.addEventListener('mousedown', (e) => { - // Only handle left mouse button - if (e.button !== 0) return; - - isDragging = true; - document.body.style.cursor = 'ns-resize'; - e.preventDefault(); - }); - } - }); - - // Global mousemove: whichever band is under cursor, set slider to cursor Y position - document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - - // Find which band is under the cursor - const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); - const bandUnderCursor = elementUnderCursor?.closest('.eq-band'); - - if (bandUnderCursor) { - const slider = bandUnderCursor.querySelector('.eq-slider'); - - if (slider) { - const rect = slider.getBoundingClientRect(); - const min = parseFloat(slider.min); - const max = parseFloat(slider.max); - const step = parseFloat(slider.step) || 0.5; - - // Calculate relative Y position within slider (0 = bottom, 1 = top) - const relativeY = (rect.bottom - e.clientY) / rect.height; - const clampedY = Math.max(0, Math.min(1, relativeY)); - - // Map to slider value range - let newValue = min + clampedY * (max - min); - - // Round to step - newValue = Math.round(newValue / step) * step; - - // Only update if value changed - if (parseFloat(slider.value) !== newValue) { - slider.value = newValue; - const bandIndex = parseInt(bandUnderCursor.dataset.band, 10); - audioContextManager.setBandGain(bandIndex, newValue); - updateBandValueDisplay(bandUnderCursor, newValue); - drawEQCurve(); - } + // Render all entries up to and past the target so the DOM element exists + while (_dbRenderedCount <= targetIdx + DB_BATCH_SIZE && _dbRenderedCount < _dbFilteredEntries.length) { + renderNextDatabaseBatch(); } - } - }); - // Global mouseup: stop dragging - document.addEventListener('mouseup', () => { - if (isDragging) { - isDragging = false; - document.body.style.cursor = ''; - } + // Now find and scroll to the element + requestAnimationFrame(() => { + const target = autoeqDatabaseList?.querySelector(`[data-letter="${letter}"]`); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + alphaContainer.appendChild(btn); }); - - // Initial curve draw with delay to ensure canvas has proper dimensions - setTimeout(() => { - drawEQCurve(); - }, 100); }; - // Initialize EQ toggle - if (eqToggle) { - const isEnabled = equalizerSettings.isEnabled(); - eqToggle.checked = isEnabled; - updateEQContainerVisibility(isEnabled); + /** + * Load and display the full headphone database + */ + // Lazy-loading state for database list + let _dbFilteredEntries = []; + let _dbRenderedCount = 0; + const DB_BATCH_SIZE = 80; - eqToggle.addEventListener('change', (e) => { - const enabled = e.target.checked; - audioContextManager.toggleEQ(enabled); - updateEQContainerVisibility(enabled); - - // Redraw curve after a brief delay to allow container to become visible - if (enabled) { - setTimeout(() => { - drawEQCurve(); - }, 50); - } - }); - } - - // Initialize band count input - if (eqBandCountInput) { - eqBandCountInput.value = currentBandCount; - - eqBandCountInput.addEventListener('change', (e) => { - const newCount = parseInt(e.target.value, 10); - if (newCount >= equalizerSettings.MIN_BANDS && newCount <= equalizerSettings.MAX_BANDS) { - currentBandCount = newCount; - - // Save new band count and update audio context (interpolates gains automatically) - equalizerSettings.setBandCount(newCount); - audioContextManager.setBandCount?.(newCount); - - // Regenerate UI - generateEQBands( - newCount, - currentRange.min, - currentRange.max, - currentFreqRange.min, - currentFreqRange.max - ); - - // Get interpolated gains from audio context - const interpolatedGains = audioContextManager.getGains?.() || equalizerSettings.getGains(newCount); - updateAllBandUI(interpolatedGains); - - // Keep current preset or set to custom if modified - if (eqPresetSelect) { - const currentPreset = eqPresetSelect.value; - if (!currentPreset.startsWith('custom_')) { - eqPresetSelect.value = 'custom'; - } - } - updateDeleteButtonVisibility(); - - // Show brief feedback - const originalText = eqBandCountInput.style.backgroundColor; - eqBandCountInput.style.backgroundColor = 'var(--highlight)'; - setTimeout(() => { - eqBandCountInput.style.backgroundColor = originalText; - }, 300); - } - }); - } - - // Initialize preset selector - if (eqPresetSelect) { - populateCustomPresets(); - eqPresetSelect.value = equalizerSettings.getPreset(); - updateDeleteButtonVisibility(); - - eqPresetSelect.addEventListener('change', (e) => { - const presetKey = e.target.value; - - // Check if it's a custom preset - if (isCustomPreset(presetKey)) { - const customPresets = equalizerSettings.getCustomPresets(); - const customPreset = customPresets[presetKey]; - if (customPreset && customPreset.gains) { - // Check if preset has different band count - const presetBands = customPreset.bandCount || customPreset.gains.length; - if (presetBands !== currentBandCount) { - // Update band count to match preset - currentBandCount = presetBands; - equalizerSettings.setBandCount(presetBands); - if (eqBandCountInput) eqBandCountInput.value = presetBands; - generateEQBands( - presetBands, - currentRange.min, - currentRange.max, - currentFreqRange.min, - currentFreqRange.max - ); - } - audioContextManager.setAllGains(customPreset.gains); - updateAllBandUI(customPreset.gains); - equalizerSettings.setPreset(presetKey); - } - } else { - // Built-in preset - use current band count - const presets = EQ_PRESETS; - const preset = presets[presetKey]; - if (preset) { - audioContextManager.applyPreset(presetKey); - updateAllBandUI(preset.gains); - } - } - updateDeleteButtonVisibility(); - }); - } - - // Initialize reset button - if (eqResetBtn) { - eqResetBtn.addEventListener('click', () => { - audioContextManager.reset(); - updateAllBandUI(new Array(currentBandCount).fill(0)); - if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - updateDeleteButtonVisibility(); - } - }); - } - - // Initialize save custom preset button - if (saveCustomPresetBtn && customPresetNameInput) { - saveCustomPresetBtn.addEventListener('click', () => { - const name = customPresetNameInput.value.trim(); - if (!name) { - alert('Please enter a name for your preset'); - return; - } - - const currentGains = audioContextManager.getGains(); - const presetId = equalizerSettings.saveCustomPreset(name, currentGains); - - if (presetId) { - populateCustomPresets(); - if (eqPresetSelect) { - eqPresetSelect.value = presetId; - equalizerSettings.setPreset(presetId); - updateDeleteButtonVisibility(); - } - customPresetNameInput.value = ''; - - // Show feedback - const originalText = saveCustomPresetBtn.textContent; - saveCustomPresetBtn.textContent = 'Saved!'; - setTimeout(() => { - saveCustomPresetBtn.textContent = originalText; - }, 1500); - } else { - alert('Failed to save preset. Please try again.'); - } - }); - - // Allow saving with Enter key - customPresetNameInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - saveCustomPresetBtn.click(); - } - }); - } - - // Initialize delete custom preset button - if (deleteCustomPresetBtn) { - deleteCustomPresetBtn.addEventListener('click', () => { - if (!eqPresetSelect) return; - - const presetId = eqPresetSelect.value; - if (!isCustomPreset(presetId)) return; - - const customPresets = equalizerSettings.getCustomPresets(); - const presetName = customPresets[presetId]?.name || 'this preset'; - - if (confirm(`Are you sure you want to delete "${presetName}"?`)) { - const success = equalizerSettings.deleteCustomPreset(presetId); - if (success) { - populateCustomPresets(); - eqPresetSelect.value = 'flat'; - audioContextManager.reset(); - updateAllBandUI(new Array(currentBandCount).fill(0)); - equalizerSettings.setPreset('flat'); - updateDeleteButtonVisibility(); - } else { - alert('Failed to delete preset. Please try again.'); - } - } - }); - } - - // Initialize range inputs - if (eqRangeMinInput) { - eqRangeMinInput.value = currentRange.min; - } - if (eqRangeMaxInput) { - eqRangeMaxInput.value = currentRange.max; - } - updateEQScale(currentRange.min, currentRange.max); - - // Initialize apply range button - if (applyEqRangeBtn && eqRangeMinInput && eqRangeMaxInput) { - applyEqRangeBtn.addEventListener('click', () => { - const newMin = parseInt(eqRangeMinInput.value, 10); - const newMax = parseInt(eqRangeMaxInput.value, 10); - - // Validate range - if (isNaN(newMin) || isNaN(newMax)) { - alert('Please enter valid numbers for the range'); - return; - } - - if (newMin >= 0 || newMax <= 0) { - alert('Minimum must be negative and maximum must be positive'); - return; - } - - if (newMin < equalizerSettings.ABSOLUTE_MIN || newMax > equalizerSettings.ABSOLUTE_MAX) { - alert( - `Range must be between ${equalizerSettings.ABSOLUTE_MIN} and ${equalizerSettings.ABSOLUTE_MAX} dB` - ); - return; - } - - // Save new range - equalizerSettings.setRange(newMin, newMax); - currentRange = { min: newMin, max: newMax }; - - // Regenerate bands with new range - generateEQBands(currentBandCount, newMin, newMax); - - // Update scale display - updateEQScale(newMin, newMax); - - // Reset gains to flat - const flatGains = new Array(currentBandCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); - - // Reset to flat preset - if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); - } - - // Show feedback - const originalText = applyEqRangeBtn.textContent; - applyEqRangeBtn.textContent = 'Applied!'; - setTimeout(() => { - applyEqRangeBtn.textContent = originalText; - }, 1500); - }); - } - - // Initialize reset DB range button - if (resetEqRangeBtn) { - resetEqRangeBtn.addEventListener('click', () => { - // Reset to default values - const defaultMin = equalizerSettings.DEFAULT_RANGE_MIN; - const defaultMax = equalizerSettings.DEFAULT_RANGE_MAX; - - // Update inputs - if (eqRangeMinInput) eqRangeMinInput.value = defaultMin; - if (eqRangeMaxInput) eqRangeMaxInput.value = defaultMax; - - // Save new range - equalizerSettings.setRange(defaultMin, defaultMax); - currentRange = { min: defaultMin, max: defaultMax }; - - // Regenerate bands with new range - generateEQBands(currentBandCount, defaultMin, defaultMax, currentFreqRange.min, currentFreqRange.max); - - // Update scale display - updateEQScale(defaultMin, defaultMax); - - // Reset gains to flat - const flatGains = new Array(currentBandCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); - - // Reset to flat preset - if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); - } - - // Show feedback - const originalText = resetEqRangeBtn.textContent; - resetEqRangeBtn.textContent = 'Reset!'; - setTimeout(() => { - resetEqRangeBtn.textContent = originalText; - }, 1500); - }); - } - - // Initialize frequency range inputs - if (eqFreqMinInput) { - eqFreqMinInput.value = currentFreqRange.min; - } - if (eqFreqMaxInput) { - eqFreqMaxInput.value = currentFreqRange.max; - } - - // Initialize apply frequency range button - if (applyEqFreqBtn && eqFreqMinInput && eqFreqMaxInput) { - applyEqFreqBtn.addEventListener('click', () => { - const newMin = parseInt(eqFreqMinInput.value, 10); - const newMax = parseInt(eqFreqMaxInput.value, 10); - - // Validate range - if (isNaN(newMin) || isNaN(newMax)) { - alert('Please enter valid numbers for the frequency range'); - return; - } - - if (newMin < equalizerSettings.ABSOLUTE_FREQ_MIN || newMax > equalizerSettings.ABSOLUTE_FREQ_MAX) { - alert( - `Frequency range must be between ${equalizerSettings.ABSOLUTE_FREQ_MIN} Hz and ${equalizerSettings.ABSOLUTE_FREQ_MAX} Hz` - ); - return; - } - - if (newMin >= newMax) { - alert('Minimum frequency must be less than maximum frequency'); - return; - } - - // Save new frequency range - equalizerSettings.setFreqRange(newMin, newMax); - currentFreqRange = { min: newMin, max: newMax }; - - // Update audio context - audioContextManager.setFreqRange(newMin, newMax); - - // Regenerate bands with new frequency range - generateEQBands(currentBandCount, currentRange.min, currentRange.max, newMin, newMax); - - // Reset gains to flat - const flatGains = new Array(currentBandCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); - - // Reset to flat preset - if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); - } - - // Show feedback - const originalText = applyEqFreqBtn.textContent; - applyEqFreqBtn.textContent = 'Applied!'; - setTimeout(() => { - applyEqFreqBtn.textContent = originalText; - }, 1500); - }); - } - - // Initialize reset frequency range button - if (resetEqFreqBtn) { - resetEqFreqBtn.addEventListener('click', () => { - // Reset to default values - const defaultMin = equalizerSettings.DEFAULT_FREQ_MIN; - const defaultMax = equalizerSettings.DEFAULT_FREQ_MAX; - - // Update inputs - if (eqFreqMinInput) eqFreqMinInput.value = defaultMin; - if (eqFreqMaxInput) eqFreqMaxInput.value = defaultMax; - - // Save new frequency range - equalizerSettings.setFreqRange(defaultMin, defaultMax); - currentFreqRange = { min: defaultMin, max: defaultMax }; - - // Update audio context - audioContextManager.setFreqRange(defaultMin, defaultMax); - - // Regenerate bands with new frequency range - generateEQBands(currentBandCount, currentRange.min, currentRange.max, defaultMin, defaultMax); - - // Reset gains to flat - const flatGains = new Array(currentBandCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); - - // Reset to flat preset - if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); - } - - // Show feedback - const originalText = resetEqFreqBtn.textContent; - resetEqFreqBtn.textContent = 'Reset!'; - setTimeout(() => { - resetEqFreqBtn.textContent = originalText; - }, 1500); - }); - } - - // Initialize preamp control - const updatePreampUI = (value) => { - currentPreamp = value; - if (eqPreampSlider) eqPreampSlider.value = value; - if (eqPreampInput) eqPreampInput.value = value; - audioContextManager.setPreamp?.(value); + const renderNextDatabaseBatch = () => { + if (_dbRenderedCount >= _dbFilteredEntries.length) return; + const end = Math.min(_dbRenderedCount + DB_BATCH_SIZE, _dbFilteredEntries.length); + const batch = _dbFilteredEntries.slice(_dbRenderedCount, end); + renderDatabaseResults(batch, true); // append mode + _dbRenderedCount = end; }; - if (eqPreampSlider) { - // Set initial value - eqPreampSlider.value = currentPreamp; + const resetDatabaseList = (entries) => { + _dbFilteredEntries = entries; + _dbRenderedCount = 0; + if (autoeqDatabaseList) autoeqDatabaseList.innerHTML = ''; + renderNextDatabaseBatch(); + }; - // Handle slider input - eqPreampSlider.addEventListener('input', (e) => { - const value = parseFloat(e.target.value); - updatePreampUI(value); - }); - } - - if (eqPreampInput) { - // Set initial value - eqPreampInput.value = currentPreamp; - - // Handle text input - eqPreampInput.addEventListener('change', (e) => { - let value = parseFloat(e.target.value); - // Clamp to valid range - value = Math.max(-20, Math.min(20, value || 0)); - updatePreampUI(value); - }); - - // Handle enter key - eqPreampInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - e.target.blur(); + // Infinite scroll on database list + if (autoeqDatabaseList) { + autoeqDatabaseList.addEventListener('scroll', () => { + const el = autoeqDatabaseList; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 60) { + renderNextDatabaseBatch(); } }); } - // Initialize import/export controls - if (eqExportBtn) { - eqExportBtn.addEventListener('click', () => { - const text = audioContextManager.exportEQToText?.(); - if (text) { - navigator.clipboard - .writeText(text) - .then(() => { - eqExportBtn.textContent = 'Copied!'; - setTimeout(() => { - eqExportBtn.textContent = 'Export'; - }, 1500); - }) - .catch(() => { - // Fallback: create and download file - const blob = new Blob([text], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'equalizer-settings.txt'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }); + const loadFullDatabase = async () => { + if (_autoeqIndex.length === 0) { + setAutoEQStatus('Loading headphone database...', ''); + try { + _autoeqIndex = await fetchAutoEqIndex(); + setAutoEQStatus(`Loaded ${_autoeqIndex.length} headphones`, 'success'); + } catch { + setAutoEQStatus('Failed to load database', 'error'); + return; } + } + if (autoeqDatabaseCount) autoeqDatabaseCount.textContent = `${_autoeqIndex.length} models`; + resetDatabaseList(_autoeqIndex); + renderAlphaIndex(); + }; + + // Search input with debounce + { + const searchEl = document.getElementById('autoeq-headphone-search'); + + if (searchEl && !searchEl._autoeqBound) { + searchEl._autoeqBound = true; + let timer = null; + + const doSearch = async () => { + const query = searchEl.value.trim(); + if (!query) { + resetDatabaseList(_autoeqIndex); + return; + } + + if (_autoeqIndex.length === 0) await loadFullDatabase(); + + const results = searchHeadphones(query, _autoeqIndex, 'all', 500); + resetDatabaseList(results); + }; + + searchEl.addEventListener('input', () => { + clearTimeout(timer); + timer = setTimeout(doSearch, 300); + }); + } + } + + // ======================================== + // AutoEQ Run + // ======================================== + if (autoeqRunBtn) { + autoeqRunBtn.addEventListener('click', () => { + if (!autoeqSelectedMeasurement) return; + + setAutoEQStatus('Running AutoEQ...', ''); + autoeqRunBtn.disabled = true; + + setTimeout(() => { + try { + const targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018'; + const targetEntry = TARGETS.find((t) => t.id === targetId); + if (!targetEntry || !targetEntry.data || targetEntry.data.length === 0) { + setAutoEQStatus('Invalid target curve', 'error'); + autoeqRunBtn.disabled = false; + return; + } + + const bandCount = autoeqBandCount ? parseInt(autoeqBandCount.value, 10) : 10; + const maxFreq = autoeqMaxFreq ? parseInt(autoeqMaxFreq.value, 10) : 16000; + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + + const bands = runAutoEqAlgorithm( + autoeqSelectedMeasurement, + targetEntry.data, + bandCount, + maxFreq, + 20, + 5.0, + sampleRate + ); + + if (!bands || bands.length === 0) { + setAutoEQStatus('No correction needed', 'success'); + autoeqRunBtn.disabled = false; + return; + } + + autoeqCurrentBands = bands; + computeCorrectedCurve(); + applyBandsToAudio(autoeqCurrentBands); + drawAutoEQGraph(); + renderBandControls(autoeqCurrentBands); + + const headphoneName = autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom'; + setAutoEQStatus(`Applied ${bands.length} bands for ${headphoneName}`, 'success'); + autoeqRunBtn.disabled = false; + } catch (err) { + console.error('[AutoEQ] Algorithm failed:', err); + setAutoEQStatus('Error: ' + err.message, 'error'); + autoeqRunBtn.disabled = false; + } + }, 50); }); } - if (eqImportBtn && eqImportFile) { - eqImportBtn.addEventListener('click', () => { - eqImportFile.click(); + // ======================================== + // Import Measurement File + // ======================================== + if (autoeqImportBtn && autoeqImportFile) { + autoeqImportBtn.addEventListener('click', () => { + autoeqImportFile.click(); }); - eqImportFile.addEventListener('change', (e) => { + autoeqImportFile.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { - const text = event.target.result; - const success = audioContextManager.importEQFromText?.(text); - if (success) { - // Update UI - currentPreamp = equalizerSettings.getPreamp(); - updatePreampUI(currentPreamp); - - // Update band count if changed - currentBandCount = equalizerSettings.getBandCount(); - if (eqBandCountInput) eqBandCountInput.value = currentBandCount; - - // Regenerate bands and update UI - generateEQBands( - currentBandCount, - currentRange.min, - currentRange.max, - currentFreqRange.min, - currentFreqRange.max - ); - const gains = audioContextManager.getGains?.() || equalizerSettings.getGains(currentBandCount); - updateAllBandUI(gains); - - eqImportBtn.textContent = 'Imported!'; - setTimeout(() => { - eqImportBtn.textContent = 'Import'; - }, 1500); - } else { - eqImportBtn.textContent = 'Invalid!'; - setTimeout(() => { - eqImportBtn.textContent = 'Import'; - }, 1500); + try { + const data = parseRawData(event.target.result); + if (data.length === 0) { + setAutoEQStatus('Invalid measurement file', 'error'); + return; + } + autoeqSelectedMeasurement = data; + autoeqSelectedEntry = { name: file.name.replace(/\.(txt|csv)$/i, ''), type: 'over-ear' }; + if (autoeqRunBtn) autoeqRunBtn.disabled = false; + drawAutoEQGraph(); + setAutoEQStatus(`Imported ${data.length} points from ${file.name}`, 'success'); + } catch { + setAutoEQStatus('Failed to parse file', 'error'); } }; reader.readAsText(file); - - // Reset file input e.target.value = ''; }); } - // Generate initial EQ bands with current ranges - generateEQBands(currentBandCount, currentRange.min, currentRange.max, currentFreqRange.min, currentFreqRange.max); + // ======================================== + // Import Target Button + // ======================================== + const autoeqImportTargetBtn = document.getElementById('autoeq-import-target-btn'); + const autoeqImportTargetFile = document.getElementById('autoeq-import-target-file'); - // Listen for band count changes from other sources - window.addEventListener('equalizer-band-count-changed', (e) => { - if (e.detail && e.detail.bandCount) { - currentBandCount = e.detail.bandCount; - if (eqBandCountInput) eqBandCountInput.value = currentBandCount; - generateEQBands( - currentBandCount, - currentRange.min, - currentRange.max, - currentFreqRange.min, - currentFreqRange.max - ); + if (autoeqImportTargetBtn && autoeqImportTargetFile) { + autoeqImportTargetBtn.addEventListener('click', () => autoeqImportTargetFile.click()); + + autoeqImportTargetFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = parseRawData(event.target.result); + if (data.length === 0) { + setAutoEQStatus('Invalid target file', 'error'); + return; + } + + const customId = 'custom_target'; + const customLabel = file.name.replace(/\.(txt|csv)$/i, ''); + + // Inject or update in TARGETS array + const existing = TARGETS.findIndex((t) => t.id === customId); + if (existing > -1) { + TARGETS[existing] = { id: customId, label: customLabel, data }; + } else { + TARGETS.push({ id: customId, label: customLabel, data }); + } + + // Add/update option in select + if (autoeqTargetSelect) { + let opt = autoeqTargetSelect.querySelector('option[value="custom_target"]'); + if (!opt) { + opt = document.createElement('option'); + opt.value = customId; + autoeqTargetSelect.appendChild(opt); + } + opt.textContent = customLabel; + autoeqTargetSelect.value = customId; + } + + computeCorrectedCurve(); + drawAutoEQGraph(); + setAutoEQStatus(`Target "${customLabel}" imported`, 'success'); + } catch { + setAutoEQStatus('Failed to parse target file', 'error'); + } + }; + reader.readAsText(file); + e.target.value = ''; + }); + } + + // ======================================== + // Download/Export Button + // ======================================== + if (autoeqDownloadBtn) { + autoeqDownloadBtn.addEventListener('click', () => { + if (!autoeqCurrentBands || autoeqCurrentBands.length === 0) { + setAutoEQStatus('No EQ to export', 'error'); + return; + } + // Build EqualizerAPO / Peace format + let lines = [`Preamp: ${currentPreamp} dB`]; + autoeqCurrentBands.forEach((band, i) => { + if (!band.enabled) return; + const type = band.type === 'peaking' ? 'PK' : band.type === 'lowshelf' ? 'LSC' : 'HSC'; + lines.push( + `Filter ${i + 1}: ON ${type} Fc ${Math.round(band.freq)} Hz Gain ${band.gain.toFixed(1)} dB Q ${band.q.toFixed(2)}` + ); + }); + const exportText = lines.join('\n'); + const blob = new Blob([exportText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `autoeq-${autoeqSelectedEntry?.name || 'custom'}.txt`; + a.click(); + URL.revokeObjectURL(url); + setAutoEQStatus('Exported', 'success'); + }); + } + + // ======================================== + // Auto Preamp Compensation Toggle + // ======================================== + if (autoPreampToggle) { + autoPreampToggle.addEventListener('change', () => { + autoPreampEnabled = autoPreampToggle.checked; + if (autoPreampEnabled) { + // Recalculate and apply auto preamp immediately + const bands = getActiveBands(); + if (bands && bands.length > 0) { + const maxGain = Math.max(0, ...bands.filter((b) => b.enabled).map((b) => b.gain)); + const autoPreamp = maxGain > 0 ? -Math.round(maxGain * 10) / 10 : 0; + currentPreamp = autoPreamp; + equalizerSettings.setPreamp(autoPreamp); + if (audioContextManager.setPreamp) audioContextManager.setPreamp(autoPreamp); + if (eqPreampSlider) eqPreampSlider.value = autoPreamp; + if (autoeqPreampValue) autoeqPreampValue.textContent = `${autoPreamp} dB`; + } + } else { + // Reset preamp to 0 dB + currentPreamp = 0; + equalizerSettings.setPreamp(0); + if (audioContextManager.setPreamp) audioContextManager.setPreamp(0); + if (eqPreampSlider) eqPreampSlider.value = 0; + if (autoeqPreampValue) autoeqPreampValue.textContent = '0 dB'; + } + }); + } + + // ======================================== + // Preamp Slider + // ======================================== + if (eqPreampSlider) { + eqPreampSlider.value = currentPreamp; + if (autoeqPreampValue) autoeqPreampValue.textContent = `${currentPreamp} dB`; + + eqPreampSlider.addEventListener('input', () => { + // Manual preamp adjustment disables auto compensation + if (autoPreampEnabled) { + autoPreampEnabled = false; + if (autoPreampToggle) autoPreampToggle.checked = false; + } + const val = parseFloat(eqPreampSlider.value); + currentPreamp = val; + equalizerSettings.setPreamp(val); + if (autoeqPreampValue) autoeqPreampValue.textContent = `${val} dB`; + if (audioContextManager.setPreamp) audioContextManager.setPreamp(val); + }); + } + + // ======================================== + // Speaker EQ State + // ======================================== + const SPEAKER_CONFIGS = { + '2.0': ['FL', 'FR'], + 5.1: ['FL', 'FR', 'C', 'LFE', 'SL', 'SR'], + 7.1: ['FL', 'FR', 'C', 'LFE', 'SL', 'SR', 'SBL', 'SBR'], + }; + const SPEAKER_CHANNEL_LABELS = { + FL: 'Front L', + FR: 'Front R', + C: 'Center', + LFE: 'Sub', + SL: 'Surr L', + SR: 'Surr R', + SBL: 'Back L', + SBR: 'Back R', + }; + let speakerConfig = '2.0'; + let speakerActiveChannel = 'FL'; + const speakerChannels = {}; + // Initialize all channels + Object.keys(SPEAKER_CHANNEL_LABELS).forEach((id) => { + speakerChannels[id] = { + measurement: null, + targetId: 'harman_room', + bands: Array.from({ length: 10 }, (_, i) => ({ + id: i, + type: 'peaking', + freq: Math.round(100 * Math.pow(2, i)), + gain: 0, + q: 1.41, + enabled: true, + })), + preamp: 0, + }; + }); + + // ======================================== + // Mode Toggle: AutoEQ vs Parametric EQ vs Speaker EQ + // ======================================== + const modeButtons = document.querySelectorAll('.autoeq-mode-btn'); + const EQ_MODE_KEY = 'eq-active-mode'; + let currentMode = 'autoeq'; + + const speakerSection = document.getElementById('speaker-eq-section'); + + const setEQMode = (mode) => { + currentMode = mode; + localStorage.setItem(EQ_MODE_KEY, mode); + modeButtons.forEach((b) => b.classList.toggle('active', b.dataset.mode === mode)); + + const graphSection = document.querySelector('.autoeq-graph-section'); + const controlsSection = document.querySelector('.autoeq-controls-section'); + const savedSection = document.getElementById('autoeq-saved-section'); + const databaseSection = document.getElementById('autoeq-database-section'); + const filtersSection = document.getElementById('autoeq-filters-section'); + const filtersContent = document.getElementById('autoeq-filters-content'); + const presetRow = document.getElementById('autoeq-preset-row'); + const parametricProfiles = document.getElementById('autoeq-parametric-profiles'); + const speakerSavedSection = document.getElementById('speaker-saved-section'); + + // Reset interactive state on switch + draggedNode = null; + hoveredNode = null; + + // Graph always visible in all modes + if (graphSection) graphSection.style.display = ''; + + // Hide all mode-specific sections first + if (controlsSection) controlsSection.style.display = 'none'; + if (savedSection) savedSection.style.display = 'none'; + if (databaseSection) databaseSection.style.display = 'none'; + if (filtersSection) filtersSection.style.display = 'none'; + if (presetRow) presetRow.style.display = 'none'; + if (parametricProfiles) parametricProfiles.style.display = 'none'; + if (speakerSection) speakerSection.style.display = 'none'; + if (speakerSavedSection) speakerSavedSection.style.display = 'none'; + + if (mode === 'autoeq') { + if (controlsSection) controlsSection.style.display = ''; + if (savedSection) savedSection.style.display = ''; + if (databaseSection) databaseSection.style.display = ''; + if (filtersSection) filtersSection.style.display = ''; + + if (autoeqCurrentBands && autoeqCurrentBands.length > 0) { + applyBandsToAudio(autoeqCurrentBands); + renderBandControls(autoeqCurrentBands); + } + computeCorrectedCurve(); + drawAutoEQGraph(); + } else if (mode === 'parametric') { + if (filtersSection) filtersSection.style.display = ''; + if (filtersContent) filtersContent.style.display = 'flex'; + if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.remove('collapsed'); + if (presetRow) presetRow.style.display = ''; + if (parametricProfiles) parametricProfiles.style.display = ''; + + if (!parametricBands || parametricBands.length === 0) { + const defaultBands = []; + for (let i = 0; i < 10; i++) { + const freq = 20 * Math.pow(20000 / 20, i / 9); + defaultBands.push({ + id: i, + type: 'peaking', + freq: Math.round(freq), + gain: 0, + q: 1.0, + enabled: true, + }); + } + parametricBands = defaultBands; + } + applyBandsToAudio(parametricBands); + renderBandControls(parametricBands); + renderParametricProfiles(); + computeCorrectedCurve(); + drawAutoEQGraph(); + } else if (mode === 'speaker') { + if (speakerSection) speakerSection.style.display = ''; + if (speakerSavedSection) speakerSavedSection.style.display = ''; + if (filtersSection) filtersSection.style.display = ''; + if (filtersContent) filtersContent.style.display = 'flex'; + if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.remove('collapsed'); + + // Apply active speaker channel bands + const ch = speakerChannels[speakerActiveChannel]; + if (ch && ch.bands.length > 0) { + applyBandsToAudio(ch.bands); + renderBandControls(ch.bands); + } + renderSpeakerChannelTabs(); + renderSpeakerProfiles(); + computeCorrectedCurve(); + drawAutoEQGraph(); } - }); - // Listen for frequency range changes from other sources - window.addEventListener('equalizer-freq-range-changed', (e) => { - if (e.detail && e.detail.min !== undefined && e.detail.max !== undefined) { - currentFreqRange = { min: e.detail.min, max: e.detail.max }; - if (eqFreqMinInput) eqFreqMinInput.value = currentFreqRange.min; - if (eqFreqMaxInput) eqFreqMaxInput.value = currentFreqRange.max; - generateEQBands( - currentBandCount, - currentRange.min, - currentRange.max, - currentFreqRange.min, - currentFreqRange.max - ); + // Update tutorial tab if visible + const hp = document.getElementById('eq-howto-panel'); + if (hp && hp.style.display !== 'none') { + const tabs = { + autoeq: document.getElementById('eq-howto-autoeq'), + parametric: document.getElementById('eq-howto-parametric'), + speaker: document.getElementById('eq-howto-speaker'), + }; + Object.values(tabs).forEach((t) => { + if (t) t.style.display = 'none'; + }); + if (tabs[mode]) tabs[mode].style.display = ''; } + }; + + modeButtons.forEach((btn) => { + btn.addEventListener('click', () => setEQMode(btn.dataset.mode)); }); - // Redraw EQ curve on window resize - window.addEventListener('resize', () => { - requestAnimationFrame(drawEQCurve); - }); + // ======================================== + // How-To Tutorial Panel + // ======================================== + const howtoBtn = document.getElementById('eq-howto-btn'); + const howtoPanel = document.getElementById('eq-howto-panel'); + const howtoClose = document.getElementById('eq-howto-close'); + const howtoTabs = { + autoeq: document.getElementById('eq-howto-autoeq'), + parametric: document.getElementById('eq-howto-parametric'), + speaker: document.getElementById('eq-howto-speaker'), + }; + + const updateHowtoTab = () => { + Object.values(howtoTabs).forEach((t) => { + if (t) t.style.display = 'none'; + }); + const active = howtoTabs[currentMode]; + if (active) active.style.display = ''; + }; + + if (howtoBtn && howtoPanel) { + howtoBtn.addEventListener('click', () => { + const visible = howtoPanel.style.display !== 'none'; + howtoPanel.style.display = visible ? 'none' : ''; + if (!visible) updateHowtoTab(); + }); + } + if (howtoClose && howtoPanel) { + howtoClose.addEventListener('click', () => { + howtoPanel.style.display = 'none'; + }); + } + + // ======================================== + // Redraw graph when target/settings change + // ======================================== + if (autoeqTargetSelect) { + autoeqTargetSelect.addEventListener('change', () => { + if (autoeqCurrentBands && autoeqSelectedMeasurement) { + computeCorrectedCurve(); + } + drawAutoEQGraph(); + }); + } + + if (autoeqBandCount) { + autoeqBandCount.addEventListener('change', () => drawAutoEQGraph()); + } + if (autoeqMaxFreq) { + autoeqMaxFreq.addEventListener('change', () => drawAutoEQGraph()); + } + if (autoeqSampleRate) { + autoeqSampleRate.addEventListener('change', () => { + if (autoeqCurrentBands && autoeqSelectedMeasurement) { + computeCorrectedCurve(); + } + drawAutoEQGraph(); + }); + } + + // ======================================== + // Parametric EQ Preset Selector + // ======================================== + const parametricPresetSelect = document.getElementById('parametric-preset-select'); + if (parametricPresetSelect) { + parametricPresetSelect.addEventListener('change', () => { + const presetKey = parametricPresetSelect.value; + if (!presetKey) return; // "Custom" selected + + ensureParametricBands(); + const bandCount = parametricBands.length; + const presets = getPresetsForBandCount(bandCount); + const preset = presets[presetKey]; + if (!preset) return; + + parametricBands.forEach((band, i) => { + band.gain = preset.gains[i] || 0; + }); + + applyBandsToAudio(parametricBands); + renderBandControls(parametricBands); + computeCorrectedCurve(); + drawAutoEQGraph(); + }); + } + + // ======================================== + // Parametric EQ Profile Save/Load/Render + // ======================================== + const PARAMETRIC_PROFILES_KEY = 'parametric-eq-profiles'; + const PARAMETRIC_ACTIVE_KEY = 'parametric-eq-active-profile'; + + const getParametricProfiles = () => { + try { + return JSON.parse(localStorage.getItem(PARAMETRIC_PROFILES_KEY)) || {}; + } catch { + return {}; + } + }; + + const renderParametricProfiles = () => { + const grid = document.getElementById('parametric-saved-grid'); + const countEl = document.getElementById('parametric-saved-count'); + if (!grid) return; + + const profiles = getParametricProfiles(); + const activeId = localStorage.getItem(PARAMETRIC_ACTIVE_KEY); + const keys = Object.keys(profiles); + if (countEl) countEl.textContent = keys.length; + grid.innerHTML = ''; + + keys.forEach((id) => { + const profile = profiles[id]; + const card = document.createElement('div'); + card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : ''); + card.dataset.profileId = id; + + const preview = document.createElement('canvas'); + preview.className = 'autoeq-profile-preview'; + preview.style.height = '80px'; + card.appendChild(preview); + + const info = document.createElement('div'); + info.className = 'autoeq-profile-info'; + info.innerHTML = ` + + ${profile.name || 'Unnamed'} + ${profile.bandCount || '?'} bands + `; + card.appendChild(info); + + const delBtn = document.createElement('button'); + delBtn.className = 'autoeq-profile-delete'; + delBtn.innerHTML = '🗑'; + delBtn.title = 'Delete profile'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const all = getParametricProfiles(); + delete all[id]; + localStorage.setItem(PARAMETRIC_PROFILES_KEY, JSON.stringify(all)); + if (localStorage.getItem(PARAMETRIC_ACTIVE_KEY) === id) localStorage.removeItem(PARAMETRIC_ACTIVE_KEY); + renderParametricProfiles(); + }); + card.appendChild(delBtn); + + card.addEventListener('click', () => { + parametricBands = profile.bands.map((b) => ({ ...b })); + applyBandsToAudio(parametricBands); + renderBandControls(parametricBands); + computeCorrectedCurve(); + drawAutoEQGraph(); + localStorage.setItem(PARAMETRIC_ACTIVE_KEY, id); + if (parametricPresetSelect) parametricPresetSelect.value = ''; + renderParametricProfiles(); + }); + + grid.appendChild(card); + + // Draw mini graph + requestAnimationFrame(() => { + drawBandsPreview(preview, profile.bands); + }); + }); + }; + + // Save parametric profile + const parametricSaveBtn = document.getElementById('parametric-save-btn'); + const parametricProfileName = document.getElementById('parametric-profile-name'); + if (parametricSaveBtn) { + parametricSaveBtn.addEventListener('click', () => { + if (!parametricBands || parametricBands.length === 0) return; + const name = parametricProfileName ? parametricProfileName.value.trim() : ''; + if (!name) return; + + const profiles = getParametricProfiles(); + const id = 'peq_' + Date.now(); + profiles[id] = { + name, + bands: parametricBands.map((b) => ({ ...b })), + bandCount: parametricBands.length, + preamp: equalizerSettings.getPreamp(), + createdAt: Date.now(), + }; + localStorage.setItem(PARAMETRIC_PROFILES_KEY, JSON.stringify(profiles)); + localStorage.setItem(PARAMETRIC_ACTIVE_KEY, id); + if (parametricProfileName) parametricProfileName.value = ''; + renderParametricProfiles(); + }); + } + + // ======================================== + // Parametric EQ Import/Export + // ======================================== + const parametricExportBtn = document.getElementById('parametric-export-btn'); + const parametricImportBtn = document.getElementById('parametric-import-btn'); + const parametricImportFile = document.getElementById('parametric-import-file'); + + if (parametricExportBtn) { + parametricExportBtn.addEventListener('click', () => { + if (!parametricBands || parametricBands.length === 0) return; + const preamp = equalizerSettings.getPreamp(); + const lines = [`Preamp: ${preamp.toFixed(1)} dB`]; + parametricBands.forEach((band, i) => { + const ft = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK'; + lines.push( + `Filter ${i + 1}: ON ${ft} Fc ${Math.round(band.freq)} Hz Gain ${band.gain.toFixed(1)} dB Q ${band.q.toFixed(2)}` + ); + }); + const text = lines.join('\n'); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'parametric-eq.txt'; + a.click(); + URL.revokeObjectURL(url); + }); + } + + if (parametricImportBtn && parametricImportFile) { + parametricImportBtn.addEventListener('click', () => parametricImportFile.click()); + parametricImportFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + try { + const text = event.target.result; + const bands = []; + let preamp = 0; + const lines = text.split('\n'); + for (const line of lines) { + const preampMatch = line.match(/Preamp:\s*([-\d.]+)\s*dB/i); + if (preampMatch) { + preamp = parseFloat(preampMatch[1]); + continue; + } + const filterMatch = line.match( + /Filter\s+\d+:\s*ON\s+(\w+)\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB\s+Q\s+([\d.]+)/i + ); + if (filterMatch) { + const typeMap = { + PK: 'peaking', + LS: 'lowshelf', + LSC: 'lowshelf', + LSF: 'lowshelf', + HS: 'highshelf', + HSC: 'highshelf', + HSF: 'highshelf', + }; + bands.push({ + id: bands.length, + type: typeMap[filterMatch[1].toUpperCase()] || 'peaking', + freq: parseFloat(filterMatch[2]), + gain: parseFloat(filterMatch[3]), + q: parseFloat(filterMatch[4]), + enabled: true, + }); + } + } + if (bands.length === 0) return; + parametricBands = bands; + applyBandsToAudio(parametricBands); + equalizerSettings.setPreamp(preamp); + if (eqPreampSlider) eqPreampSlider.value = preamp; + if (autoeqPreampValue) autoeqPreampValue.textContent = `${preamp} dB`; + renderBandControls(parametricBands); + computeCorrectedCurve(); + drawAutoEQGraph(); + if (parametricPresetSelect) parametricPresetSelect.value = ''; + } catch (err) { + console.error('[PEQ Import] Failed:', err); + } + }; + reader.readAsText(file); + e.target.value = ''; + }); + } + + // ======================================== + // Speaker EQ Logic + // ======================================== + const speakerConfigSelect = document.getElementById('speaker-config-select'); + const speakerChannelTabsEl = document.getElementById('speaker-channel-tabs'); + const speakerMeasStatus = document.getElementById('speaker-measurement-status'); + const speakerImportMeasBtn = document.getElementById('speaker-import-measurement-btn'); + const speakerImportMeasFile = document.getElementById('speaker-import-measurement-file'); + const speakerClearMeasBtn = document.getElementById('speaker-clear-measurement-btn'); + const speakerTargetSelect = document.getElementById('speaker-target-select'); + const speakerImportTargetBtn = document.getElementById('speaker-import-target-btn'); + const speakerImportTargetFile = document.getElementById('speaker-import-target-file'); + const speakerBandCountSelect = document.getElementById('speaker-band-count'); + const speakerBassCutoff = document.getElementById('speaker-bass-cutoff'); + const speakerBassCutoffValue = document.getElementById('speaker-bass-cutoff-value'); + const speakerRoomLimit = document.getElementById('speaker-room-limit'); + const speakerRoomLimitValue = document.getElementById('speaker-room-limit-value'); + const speakerAutoEqBtn = document.getElementById('speaker-autoeq-btn'); + const speakerEqStatus = document.getElementById('speaker-eq-status'); + const speakerExportBtn = document.getElementById('speaker-export-btn'); + + const getSpeakerChannel = () => speakerChannels[speakerActiveChannel]; + + const renderSpeakerChannelTabs = () => { + if (!speakerChannelTabsEl) return; + const ids = SPEAKER_CONFIGS[speakerConfig]; + speakerChannelTabsEl.innerHTML = ''; + ids.forEach((id) => { + const btn = document.createElement('button'); + btn.className = 'speaker-channel-tab' + (id === speakerActiveChannel ? ' active' : ''); + btn.textContent = id; + btn.title = SPEAKER_CHANNEL_LABELS[id]; + if (speakerChannels[id].measurement) btn.classList.add('has-data'); + btn.addEventListener('click', () => { + speakerActiveChannel = id; + renderSpeakerChannelTabs(); + updateSpeakerUI(); + // Apply this channel's bands to audio + graph + const ch = getSpeakerChannel(); + applyBandsToAudio(ch.bands); + renderBandControls(ch.bands); + drawAutoEQGraph(); + }); + speakerChannelTabsEl.appendChild(btn); + }); + }; + + const updateSpeakerUI = () => { + const ch = getSpeakerChannel(); + // Measurement status + if (speakerMeasStatus) { + speakerMeasStatus.textContent = ch.measurement ? `${ch.measurement.length} pts` : 'No measurement'; + speakerMeasStatus.classList.toggle('loaded', !!ch.measurement); + } + if (speakerClearMeasBtn) speakerClearMeasBtn.style.display = ch.measurement ? '' : 'none'; + if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = !ch.measurement; + // Target + if (speakerTargetSelect) speakerTargetSelect.value = ch.targetId; + // Preamp + }; + + // Config change + if (speakerConfigSelect) { + speakerConfigSelect.addEventListener('change', () => { + speakerConfig = speakerConfigSelect.value; + const ids = SPEAKER_CONFIGS[speakerConfig]; + if (!ids.includes(speakerActiveChannel)) speakerActiveChannel = ids[0]; + renderSpeakerChannelTabs(); + updateSpeakerUI(); + }); + } + + // Import measurement + if (speakerImportMeasBtn && speakerImportMeasFile) { + speakerImportMeasBtn.addEventListener('click', () => speakerImportMeasFile.click()); + speakerImportMeasFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const data = parseRawData(ev.target.result); + if (data.length > 0) { + getSpeakerChannel().measurement = data; + updateSpeakerUI(); + renderSpeakerChannelTabs(); + drawAutoEQGraph(); + } + }; + reader.readAsText(file); + e.target.value = ''; + }); + } + + // Clear measurement + if (speakerClearMeasBtn) { + speakerClearMeasBtn.addEventListener('click', () => { + getSpeakerChannel().measurement = null; + updateSpeakerUI(); + renderSpeakerChannelTabs(); + drawAutoEQGraph(); + }); + } + + // Pink noise room measurement + const speakerMeasureBtn = document.getElementById('speaker-measure-btn'); + if (speakerMeasureBtn) { + speakerMeasureBtn.addEventListener('click', async () => { + speakerMeasureBtn.disabled = true; + if (speakerMeasStatus) { + speakerMeasStatus.textContent = 'Requesting mic...'; + speakerMeasStatus.classList.remove('loaded'); + } + + let measCtx, stream; + try { + // 1. Get mic with processing disabled + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, + }); + + measCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 }); + const sr = measCtx.sampleRate; + const duration = 5; + + // 2. Generate pink noise buffer (Voss algorithm approximation) + const bufLen = sr * duration; + const buffer = measCtx.createBuffer(1, bufLen, sr); + const data = buffer.getChannelData(0); + // Paul Kellet's refined pink noise filter coefficients + let b0 = 0, + b1 = 0, + b2 = 0, + b3 = 0, + b4 = 0, + b5 = 0, + b6 = 0; + for (let i = 0; i < bufLen; i++) { + const white = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.969 * b2 + white * 0.153852; + b3 = 0.8665 * b3 + white * 0.3104856; + b4 = 0.55 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.016898; + let pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; + b6 = white * 0.115926; + // Fade in/out envelope (100ms) + let env = 1; + const t = i / sr; + if (t < 0.1) env = t / 0.1; + else if (t > duration - 0.1) env = (duration - t) / 0.1; + data[i] = pink * 0.04 * env; // low amplitude + } + + // 3. Play pink noise + const noiseSource = measCtx.createBufferSource(); + noiseSource.buffer = buffer; + noiseSource.connect(measCtx.destination); + + // 4. Setup mic analyser + const micSource = measCtx.createMediaStreamSource(stream); + const analyser = measCtx.createAnalyser(); + analyser.fftSize = 8192; + analyser.smoothingTimeConstant = 0.3; + micSource.connect(analyser); + + const freqBinCount = analyser.frequencyBinCount; + const binHz = sr / analyser.fftSize; + const fftData = new Float32Array(freqBinCount); + const accumulator = new Float64Array(freqBinCount); + let frameCount = 0; + + // 5. Start playback + capture loop + noiseSource.start(); + const startTime = measCtx.currentTime; + + await new Promise((resolve) => { + const tick = () => { + const elapsed = measCtx.currentTime - startTime; + if (elapsed >= duration) { + resolve(); + return; + } + + // Update progress + const pct = Math.round((elapsed / duration) * 100); + if (speakerMeasStatus) speakerMeasStatus.textContent = `Measuring... ${pct}%`; + + // Skip first 0.3s (let noise settle) + if (elapsed > 0.3) { + analyser.getFloatFrequencyData(fftData); + for (let j = 0; j < freqBinCount; j++) { + const val = fftData[j]; + if (val !== -Infinity) accumulator[j] += val; + } + frameCount++; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + + noiseSource.stop(); + + // 6. Post-process: average bins → log-spaced points + if (frameCount === 0) throw new Error('No frames captured'); + for (let j = 0; j < freqBinCount; j++) accumulator[j] /= frameCount; + + const points = []; + const ptsPerOctave = 24; + let freq = 20; + while (freq <= 20000) { + const binIdx = Math.round(freq / binHz); + if (binIdx >= 0 && binIdx < freqBinCount) { + // Average a few bins around target for smoothing + const lo = Math.max(0, binIdx - 2); + const hi = Math.min(freqBinCount - 1, binIdx + 2); + let sum = 0, + cnt = 0; + for (let k = lo; k <= hi; k++) { + sum += accumulator[k]; + cnt++; + } + points.push({ freq, gain: sum / cnt }); + } + freq *= Math.pow(2, 1 / ptsPerOctave); + } + + // Normalize: midrange (500-2000 Hz) average → 75 dB + const midPts = points.filter((p) => p.freq >= 500 && p.freq <= 2000); + const midAvg = midPts.length > 0 ? midPts.reduce((s, p) => s + p.gain, 0) / midPts.length : 0; + const offset = 75 - midAvg; + const normalized = points.map((p) => ({ freq: p.freq, gain: p.gain + offset })); + + // 7. Store result + getSpeakerChannel().measurement = normalized; + updateSpeakerUI(); + renderSpeakerChannelTabs(); + computeCorrectedCurve(); + drawAutoEQGraph(); + if (speakerMeasStatus) speakerMeasStatus.textContent = `${normalized.length} pts (measured)`; + } catch (err) { + console.error('[Speaker Measure]', err); + if (speakerMeasStatus) + speakerMeasStatus.textContent = err.name === 'NotAllowedError' ? 'Mic denied' : 'Measure failed'; + } finally { + // Cleanup + if (stream) stream.getTracks().forEach((t) => t.stop()); + if (measCtx && measCtx.state !== 'closed') measCtx.close().catch(() => {}); + speakerMeasureBtn.disabled = false; + } + }); + } + + // Measure All — plays pink noise once, assigns averaged measurement to all active channels + const speakerMeasureAllBtn = document.getElementById('speaker-measure-all-btn'); + if (speakerMeasureAllBtn) { + speakerMeasureAllBtn.addEventListener('click', async () => { + speakerMeasureAllBtn.disabled = true; + if (speakerMeasureBtn) speakerMeasureBtn.disabled = true; + if (speakerMeasStatus) { + speakerMeasStatus.textContent = 'Requesting mic...'; + speakerMeasStatus.classList.remove('loaded'); + } + + let measCtx, stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, + }); + + measCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 }); + const sr = measCtx.sampleRate; + const duration = 5; + + // Generate pink noise buffer + const bufLen = sr * duration; + const buffer = measCtx.createBuffer(1, bufLen, sr); + const d = buffer.getChannelData(0); + let b0 = 0, + b1 = 0, + b2 = 0, + b3 = 0, + b4 = 0, + b5 = 0, + b6 = 0; + for (let i = 0; i < bufLen; i++) { + const white = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.969 * b2 + white * 0.153852; + b3 = 0.8665 * b3 + white * 0.3104856; + b4 = 0.55 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.016898; + let pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; + b6 = white * 0.115926; + let env = 1; + const t = i / sr; + if (t < 0.1) env = t / 0.1; + else if (t > duration - 0.1) env = (duration - t) / 0.1; + d[i] = pink * 0.04 * env; + } + + const noiseSource = measCtx.createBufferSource(); + noiseSource.buffer = buffer; + noiseSource.connect(measCtx.destination); + + const micSource = measCtx.createMediaStreamSource(stream); + const analyser = measCtx.createAnalyser(); + analyser.fftSize = 8192; + analyser.smoothingTimeConstant = 0.3; + micSource.connect(analyser); + + const freqBinCount = analyser.frequencyBinCount; + const binHz = sr / analyser.fftSize; + const fftData = new Float32Array(freqBinCount); + const accumulator = new Float64Array(freqBinCount); + let frameCount = 0; + + noiseSource.start(); + const startTime = measCtx.currentTime; + + await new Promise((resolve) => { + const tick = () => { + const elapsed = measCtx.currentTime - startTime; + if (elapsed >= duration) { + resolve(); + return; + } + const pct = Math.round((elapsed / duration) * 100); + if (speakerMeasStatus) speakerMeasStatus.textContent = `Measuring all... ${pct}%`; + if (elapsed > 0.3) { + analyser.getFloatFrequencyData(fftData); + for (let j = 0; j < freqBinCount; j++) { + const val = fftData[j]; + if (val !== -Infinity) accumulator[j] += val; + } + frameCount++; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + + noiseSource.stop(); + + if (frameCount === 0) throw new Error('No frames captured'); + for (let j = 0; j < freqBinCount; j++) accumulator[j] /= frameCount; + + const points = []; + const ptsPerOctave = 24; + let freq = 20; + while (freq <= 20000) { + const binIdx = Math.round(freq / binHz); + if (binIdx >= 0 && binIdx < freqBinCount) { + const lo = Math.max(0, binIdx - 2); + const hi = Math.min(freqBinCount - 1, binIdx + 2); + let sum = 0, + cnt = 0; + for (let k = lo; k <= hi; k++) { + sum += accumulator[k]; + cnt++; + } + points.push({ freq, gain: sum / cnt }); + } + freq *= Math.pow(2, 1 / ptsPerOctave); + } + + const midPts = points.filter((p) => p.freq >= 500 && p.freq <= 2000); + const midAvg = midPts.length > 0 ? midPts.reduce((s, p) => s + p.gain, 0) / midPts.length : 0; + const offset = 75 - midAvg; + const normalized = points.map((p) => ({ freq: p.freq, gain: p.gain + offset })); + + // Assign to ALL active channels + const activeIds = SPEAKER_CONFIGS[speakerConfig]; + activeIds.forEach((id) => { + speakerChannels[id].measurement = normalized.map((p) => ({ ...p })); + }); + + updateSpeakerUI(); + renderSpeakerChannelTabs(); + computeCorrectedCurve(); + drawAutoEQGraph(); + if (speakerMeasStatus) + speakerMeasStatus.textContent = `${normalized.length} pts → ${activeIds.length} channels`; + } catch (err) { + console.error('[Speaker Measure All]', err); + if (speakerMeasStatus) + speakerMeasStatus.textContent = err.name === 'NotAllowedError' ? 'Mic denied' : 'Measure failed'; + } finally { + if (stream) stream.getTracks().forEach((t) => t.stop()); + if (measCtx && measCtx.state !== 'closed') measCtx.close().catch(() => {}); + speakerMeasureAllBtn.disabled = false; + if (speakerMeasureBtn) speakerMeasureBtn.disabled = false; + } + }); + } + + // AutoEQ All — runs AutoEQ on every active channel that has a measurement + const speakerAutoEqAllBtn = document.getElementById('speaker-autoeq-all-btn'); + if (speakerAutoEqAllBtn) { + speakerAutoEqAllBtn.addEventListener('click', () => { + const activeIds = SPEAKER_CONFIGS[speakerConfig]; + const measuredIds = activeIds.filter((id) => speakerChannels[id].measurement); + if (measuredIds.length === 0) return; + + speakerAutoEqAllBtn.disabled = true; + if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = true; + if (speakerEqStatus) speakerEqStatus.textContent = 'Running all...'; - // Redraw EQ curve when a new track loads (audio metadata loaded) - const audioPlayer = document.getElementById('audio-player'); - if (audioPlayer) { - audioPlayer.addEventListener('loadedmetadata', () => { - // Small delay to ensure the visualizer and EQ are fully ready setTimeout(() => { - drawEQCurve(); + const bandCount = speakerBandCountSelect ? parseInt(speakerBandCountSelect.value, 10) : 10; + const bassCut = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40; + const roomLim = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500; + + measuredIds.forEach((id) => { + const ch = speakerChannels[id]; + const targetEntry = SPEAKER_TARGETS.find((t) => t.id === ch.targetId); + const targetData = targetEntry?.data || []; + + const bands = runAutoEqAlgorithm(ch.measurement, targetData, bandCount, roomLim, bassCut, 3.0); + + let maxGain = 0; + for (let f = 20; f <= 20000; f *= 1.1) { + let total = 0; + bands.forEach((b) => { + if (b.enabled) total += calculateBiquadResponse(f, b); + }); + if (total > maxGain) maxGain = total; + } + ch.bands = bands; + ch.preamp = maxGain > 0 ? parseFloat((-maxGain - 0.1).toFixed(1)) : 0; + }); + + // Refresh active channel UI + const ch = getSpeakerChannel(); + applyBandsToAudio(ch.bands); + renderBandControls(ch.bands); + updateSpeakerUI(); + renderSpeakerChannelTabs(); + computeCorrectedCurve(); + drawAutoEQGraph(); + + speakerAutoEqAllBtn.disabled = false; + if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = !ch.measurement; + if (speakerEqStatus) speakerEqStatus.textContent = `${measuredIds.length} channels optimized`; + setTimeout(() => { + if (speakerEqStatus) speakerEqStatus.textContent = ''; + }, 3000); }, 100); }); } + // Target change + if (speakerTargetSelect) { + speakerTargetSelect.addEventListener('change', () => { + getSpeakerChannel().targetId = speakerTargetSelect.value; + drawAutoEQGraph(); + }); + } + + // Import custom speaker target + if (speakerImportTargetBtn && speakerImportTargetFile) { + speakerImportTargetBtn.addEventListener('click', () => speakerImportTargetFile.click()); + speakerImportTargetFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const data = parseRawData(ev.target.result); + if (data.length === 0) return; + const customId = 'custom_speaker_target'; + const label = file.name.replace(/\.(txt|csv)$/i, ''); + const existing = SPEAKER_TARGETS.findIndex((t) => t.id === customId); + if (existing > -1) SPEAKER_TARGETS[existing] = { id: customId, label, data }; + else SPEAKER_TARGETS.push({ id: customId, label, data }); + let opt = speakerTargetSelect.querySelector('option[value="custom_speaker_target"]'); + if (!opt) { + opt = document.createElement('option'); + opt.value = customId; + speakerTargetSelect.appendChild(opt); + } + opt.textContent = label; + speakerTargetSelect.value = customId; + getSpeakerChannel().targetId = customId; + drawAutoEQGraph(); + }; + reader.readAsText(file); + e.target.value = ''; + }); + } + + // Slider labels + if (speakerBassCutoff) { + speakerBassCutoff.addEventListener('input', () => { + if (speakerBassCutoffValue) speakerBassCutoffValue.textContent = `${speakerBassCutoff.value} Hz`; + drawAutoEQGraph(); + }); + } + if (speakerRoomLimit) { + speakerRoomLimit.addEventListener('input', () => { + if (speakerRoomLimitValue) speakerRoomLimitValue.textContent = `${speakerRoomLimit.value} Hz`; + drawAutoEQGraph(); + }); + } + // AutoEQ per channel + if (speakerAutoEqBtn) { + speakerAutoEqBtn.addEventListener('click', () => { + const ch = getSpeakerChannel(); + if (!ch.measurement) return; + speakerAutoEqBtn.disabled = true; + if (speakerEqStatus) speakerEqStatus.textContent = 'Running...'; + + setTimeout(() => { + const targetEntry = SPEAKER_TARGETS.find((t) => t.id === ch.targetId); + const targetData = targetEntry?.data || []; + const bandCount = speakerBandCountSelect ? parseInt(speakerBandCountSelect.value, 10) : 10; + const bassCut = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40; + const roomLim = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500; + + const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + const bands = runAutoEqAlgorithm( + ch.measurement, + targetData, + bandCount, + roomLim, + bassCut, + 3.0, + sampleRate + ); + + // Auto preamp + let maxGain = 0; + for (let f = 20; f <= 20000; f *= 1.1) { + let total = 0; + bands.forEach((b) => { + if (b.enabled) total += calculateBiquadResponse(f, b, sampleRate); + }); + if (total > maxGain) maxGain = total; + } + const autoPreamp = maxGain > 0 ? parseFloat((-maxGain - 0.1).toFixed(1)) : 0; + + ch.bands = bands; + ch.preamp = autoPreamp; + + applyBandsToAudio(bands); + renderBandControls(bands); + updateSpeakerUI(); + renderSpeakerChannelTabs(); + computeCorrectedCurve(); + drawAutoEQGraph(); + + speakerAutoEqBtn.disabled = false; + if (speakerEqStatus) speakerEqStatus.textContent = `${speakerActiveChannel} optimized`; + setTimeout(() => { + if (speakerEqStatus) speakerEqStatus.textContent = ''; + }, 3000); + }, 100); + }); + } + + // Export all channels as JSON + if (speakerExportBtn) { + speakerExportBtn.addEventListener('click', () => { + const activeIds = SPEAKER_CONFIGS[speakerConfig]; + const data = { + config: speakerConfig, + channels: activeIds.map((id) => { + const ch = speakerChannels[id]; + return { + id, + label: SPEAKER_CHANNEL_LABELS[id], + preamp: ch.preamp, + filters: ch.bands + .filter((b) => b.enabled) + .map((b) => ({ + type: b.type, + freq: b.freq, + gain: b.gain, + q: b.q, + })), + }; + }), + }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `SpeakerEQ_${speakerConfig}_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }); + } + + // Import EQ settings from JSON + const speakerImportBtn = document.getElementById('speaker-import-btn'); + const speakerImportFile = document.getElementById('speaker-import-file'); + if (speakerImportBtn && speakerImportFile) { + speakerImportBtn.addEventListener('click', () => speakerImportFile.click()); + speakerImportFile.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + if (!data.config || !Array.isArray(data.channels)) { + throw new Error('Invalid JSON format'); + } + // Change config if different + if (data.config !== speakerConfig) { + speakerConfig = data.config; + if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig; + } + // Load channels + data.channels.forEach((ch) => { + if (speakerChannels[ch.id]) { + speakerChannels[ch.id].preamp = ch.preamp || 0; + speakerChannels[ch.id].bands = ch.filters.map((f) => ({ + enabled: true, + type: f.type, + freq: f.freq, + gain: f.gain, + q: f.q, + })); + } + }); + // Update UI + speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0]; + renderSpeakerChannelTabs(); + setEQMode('speaker'); + if (speakerEqStatus) speakerEqStatus.textContent = `Loaded: ${data.channels.length} channels`; + setTimeout(() => { + if (speakerEqStatus) speakerEqStatus.textContent = ''; + }, 2000); + } catch (err) { + if (speakerEqStatus) speakerEqStatus.textContent = `Error: ${err.message}`; + } + speakerImportFile.value = ''; + }); + } + + // ======================================== + // Speaker Saved Profiles + // ======================================== + const SPEAKER_PROFILES_IDB_KEY = 'speaker-eq-profiles'; + const SPEAKER_ACTIVE_PROFILE_KEY = 'speaker-eq-active-profile'; + let _speakerProfilesCache = null; // in-memory cache backed by IndexedDB + + const getSpeakerProfiles = () => _speakerProfilesCache || {}; + + const loadSpeakerProfilesFromDB = async () => { + try { + // Migrate from localStorage if present + const lsData = localStorage.getItem('speaker-eq-profiles'); + if (lsData) { + const parsed = JSON.parse(lsData); + if (parsed && Object.keys(parsed).length > 0) { + await db.saveSetting(SPEAKER_PROFILES_IDB_KEY, parsed); + } + localStorage.removeItem('speaker-eq-profiles'); + } + } catch { + /* ignore migration errors */ + } + try { + _speakerProfilesCache = (await db.getSetting(SPEAKER_PROFILES_IDB_KEY)) || {}; + } catch { + _speakerProfilesCache = {}; + } + }; + + const saveSpeakerProfiles = async (profiles) => { + _speakerProfilesCache = profiles; + await db.saveSetting(SPEAKER_PROFILES_IDB_KEY, profiles); + }; + + await loadSpeakerProfilesFromDB(); + + const renderSpeakerProfiles = () => { + const grid = document.getElementById('speaker-saved-grid'); + const countEl = document.getElementById('speaker-saved-count'); + if (!grid) return; + + const profiles = getSpeakerProfiles(); + const activeId = localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY); + const keys = Object.keys(profiles); + if (countEl) countEl.textContent = keys.length; + grid.innerHTML = ''; + + if (keys.length === 0) return; + + keys.forEach((id) => { + const profile = profiles[id]; + const card = document.createElement('div'); + card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : ''); + + const preview = document.createElement('canvas'); + preview.className = 'autoeq-profile-preview'; + preview.style.height = '80px'; + card.appendChild(preview); + + const channelCount = profile.channels ? profile.channels.length : 0; + const info = document.createElement('div'); + info.className = 'autoeq-profile-info'; + info.innerHTML = ` + + ${profile.name || 'Unnamed'} + ${profile.config} · ${channelCount} ch + `; + card.appendChild(info); + + const delBtn = document.createElement('button'); + delBtn.className = 'autoeq-profile-delete'; + delBtn.innerHTML = '🗑'; + delBtn.title = 'Delete profile'; + delBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const all = getSpeakerProfiles(); + delete all[id]; + await saveSpeakerProfiles(all); + if (localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY) === id) + localStorage.removeItem(SPEAKER_ACTIVE_PROFILE_KEY); + renderSpeakerProfiles(); + }); + card.appendChild(delBtn); + + // Click to load + card.addEventListener('click', () => { + loadSpeakerProfile(id); + }); + + grid.appendChild(card); + + // Draw mini preview from first channel's measurement + requestAnimationFrame(() => { + const firstCh = profile.channels?.[0]; + if (firstCh && firstCh.measurementPreview) { + const targetEntry = SPEAKER_TARGETS.find((t) => t.id === (firstCh.targetId || 'harman_room')); + drawMiniGraph( + preview, + firstCh.measurementPreview, + targetEntry?.data ? downsampleCurve(targetEntry.data) : null, + firstCh.correctedPreview || null + ); + } + }); + }); + }; + + const loadSpeakerProfile = (profileId) => { + const profiles = getSpeakerProfiles(); + const profile = profiles[profileId]; + if (!profile) return; + + // Switch config if different + if (profile.config && profile.config !== speakerConfig) { + speakerConfig = profile.config; + if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig; + } + + // Load all channels + if (profile.channels) { + profile.channels.forEach((saved) => { + if (speakerChannels[saved.id]) { + speakerChannels[saved.id].measurement = saved.measurement || null; + speakerChannels[saved.id].targetId = saved.targetId || 'harman_room'; + speakerChannels[saved.id].preamp = saved.preamp || 0; + speakerChannels[saved.id].bands = saved.bands + ? saved.bands.map((b) => ({ ...b })) + : speakerChannels[saved.id].bands; + } + }); + } + + speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0]; + const ch = getSpeakerChannel(); + applyBandsToAudio(ch.bands); + renderBandControls(ch.bands); + updateSpeakerUI(); + renderSpeakerChannelTabs(); + computeCorrectedCurve(); + drawAutoEQGraph(); + + localStorage.setItem(SPEAKER_ACTIVE_PROFILE_KEY, profileId); + renderSpeakerProfiles(); + if (speakerEqStatus) speakerEqStatus.textContent = `Loaded "${profile.name}"`; + setTimeout(() => { + if (speakerEqStatus) speakerEqStatus.textContent = ''; + }, 2000); + }; + + // Save button + const speakerSaveBtn = document.getElementById('speaker-save-btn'); + const speakerProfileNameInput = document.getElementById('speaker-profile-name'); + if (speakerSaveBtn) { + speakerSaveBtn.addEventListener('click', async () => { + try { + const name = speakerProfileNameInput?.value.trim() || `Speaker ${speakerConfig}`; + const activeIds = SPEAKER_CONFIGS[speakerConfig]; + const profiles = getSpeakerProfiles(); + const id = 'spk_' + Date.now(); + + profiles[id] = { + name, + config: speakerConfig, + channels: activeIds.map((chId) => { + const ch = speakerChannels[chId]; + return { + id: chId, + targetId: ch.targetId, + preamp: ch.preamp, + bands: ch.bands.map((b) => ({ ...b })), + measurement: ch.measurement + ? ch.measurement.map((p) => ({ freq: p.freq, gain: parseFloat(p.gain.toFixed(1)) })) + : null, + measurementPreview: ch.measurement ? downsampleCurve(ch.measurement) : null, + correctedPreview: + autoeqCorrectedCurve && chId === speakerActiveChannel + ? downsampleCurve(autoeqCorrectedCurve) + : null, + }; + }), + createdAt: Date.now(), + }; + + await saveSpeakerProfiles(profiles); + localStorage.setItem(SPEAKER_ACTIVE_PROFILE_KEY, id); + if (speakerProfileNameInput) speakerProfileNameInput.value = ''; + renderSpeakerProfiles(); + if (speakerEqStatus) speakerEqStatus.textContent = `Saved "${name}"`; + setTimeout(() => { + if (speakerEqStatus) speakerEqStatus.textContent = ''; + }, 2000); + } catch (err) { + console.error('[Speaker Save]', err); + if (speakerEqStatus) speakerEqStatus.textContent = `Save failed: ${err.message}`; + } + }); + } + + // Collapse toggle for speaker saved section + const speakerSavedCollapse = document.getElementById('speaker-saved-collapse'); + const speakerSavedGrid = document.getElementById('speaker-saved-grid'); + if (speakerSavedCollapse && speakerSavedGrid) { + speakerSavedCollapse.addEventListener('click', () => { + speakerSavedCollapse.classList.toggle('collapsed'); + speakerSavedGrid.style.display = speakerSavedCollapse.classList.contains('collapsed') ? 'none' : ''; + }); + } + + // ======================================== + // Add/Remove/Reset Band Buttons + // ======================================== + const addBandBtn = document.getElementById('autoeq-add-band-btn'); + const removeBandBtn = document.getElementById('autoeq-remove-band-btn'); + const resetBandsBtn = document.getElementById('autoeq-reset-bands-btn'); + + if (addBandBtn) { + addBandBtn.addEventListener('click', () => { + let bands = getActiveBands(); + if (!bands) { + bands = []; + setActiveBands(bands); + } + if (bands.length >= 32) return; + bands.push({ id: bands.length, type: 'peaking', freq: 1000, gain: 0, q: 1.0, enabled: true }); + applyBandsToAudio(bands); + renderBandControls(bands); + computeCorrectedCurve(); + drawAutoEQGraph(); + }); + } + + if (removeBandBtn) { + removeBandBtn.addEventListener('click', () => { + const bands = getActiveBands(); + if (!bands || bands.length <= 1) return; + bands.pop(); + applyBandsToAudio(bands); + renderBandControls(bands); + computeCorrectedCurve(); + drawAutoEQGraph(); + }); + } + + if (resetBandsBtn) { + resetBandsBtn.addEventListener('click', () => { + const bands = getActiveBands(); + if (!bands) return; + bands.forEach((b) => { + b.gain = 0; + }); + applyBandsToAudio(bands); + renderBandControls(bands); + computeCorrectedCurve(); + drawAutoEQGraph(); + }); + } + + // ======================================== + // EQ Toggle (enable/disable) + // ======================================== + if (eqToggle) { + eqToggle.checked = equalizerSettings.isEnabled(); + updateEQContainerVisibility(eqToggle.checked); + + eqToggle.addEventListener('change', (e) => { + const enabled = e.target.checked; + equalizerSettings.setEnabled(enabled); + updateEQContainerVisibility(enabled); + + audioContextManager.toggleEQ(enabled); + }); + } + + // Initial render of saved profiles + renderSavedProfiles(); + + // Hide parametric-only elements on startup (default mode is autoeq) + const initPresetRow = document.getElementById('autoeq-preset-row'); + const initParaProfiles = document.getElementById('autoeq-parametric-profiles'); + if (initPresetRow) initPresetRow.style.display = 'none'; + if (initParaProfiles) initParaProfiles.style.display = 'none'; + + // Auto-load headphone database + loadFullDatabase(); + + // Auto-load default popular headphone if no saved profile is active + const activeProfileId = equalizerSettings.getActiveAutoEQProfile(); + if (!activeProfileId) { + // Try restoring last selected headphone (persisted measurement + entry) + const lastHp = equalizerSettings.getLastHeadphone(); + if (lastHp) { + autoeqSelectedMeasurement = lastHp.measurementData; + autoeqSelectedEntry = lastHp.entry; + if (autoeqHeadphoneSelect) { + let opt = autoeqHeadphoneSelect.querySelector(`option[value="${lastHp.entry.name}"]`); + if (!opt) { + opt = document.createElement('option'); + opt.value = lastHp.entry.name; + opt.textContent = lastHp.entry.name.replace(/\s*\([^)]*\)\s*$/, ''); + autoeqHeadphoneSelect.appendChild(opt); + } + autoeqHeadphoneSelect.value = lastHp.entry.name; + } + if (autoeqRunBtn) autoeqRunBtn.disabled = false; + requestAnimationFrame(drawAutoEQGraph); + } else if (POPULAR_HEADPHONES.length > 0) { + loadHeadphoneEntry(POPULAR_HEADPHONES[0]); + } + } + + // Initial draw of graph (if EQ is enabled) + if (equalizerSettings.isEnabled()) { + requestAnimationFrame(drawAutoEQGraph); + } + + // Load active profile on startup + if (activeProfileId) { + const profiles = equalizerSettings.getAutoEQProfiles(); + if (profiles[activeProfileId]) { + // Restore state silently + const profile = profiles[activeProfileId]; + autoeqCurrentBands = profile.bands?.map((b) => ({ ...b })) || null; + autoeqCorrectedCurve = profile.correctedData ? [...profile.correctedData] : null; + autoeqSelectedMeasurement = profile.measurementData ? [...profile.measurementData] : null; + autoeqSelectedEntry = { name: profile.headphoneName, type: profile.headphoneType }; + // Restore headphone select dropdown + if (autoeqHeadphoneSelect) { + let opt = autoeqHeadphoneSelect.querySelector(`option[value="${profile.headphoneName}"]`); + if (!opt) { + opt = document.createElement('option'); + opt.value = profile.headphoneName; + opt.textContent = profile.headphoneName.replace(/\s*\([^)]*\)\s*$/, ''); + autoeqHeadphoneSelect.appendChild(opt); + } + autoeqHeadphoneSelect.value = profile.headphoneName; + } + if (autoeqTargetSelect) autoeqTargetSelect.value = profile.targetId || 'harman_oe_2018'; + setAutoeqBandCount(profile.bandCount, profile.bands); + if (autoeqMaxFreq) autoeqMaxFreq.value = profile.maxFreq || 16000; + if (autoeqSampleRate) autoeqSampleRate.value = profile.sampleRate || 48000; + if (autoeqRunBtn) autoeqRunBtn.disabled = false; + if (autoeqCurrentBands) renderBandControls(autoeqCurrentBands); + requestAnimationFrame(drawAutoEQGraph); + } + } + + // Restore parametric EQ active profile on startup + const activeParametricId = localStorage.getItem(PARAMETRIC_ACTIVE_KEY); + if (activeParametricId) { + const parametricProfiles = getParametricProfiles(); + const paraProfile = parametricProfiles[activeParametricId]; + if (paraProfile && paraProfile.bands) { + parametricBands = paraProfile.bands.map((b) => ({ ...b })); + } + } + + // Restore speaker EQ active profile on startup + const activeSpeakerId = localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY); + if (activeSpeakerId) { + const speakerProfiles = getSpeakerProfiles(); + const spkProfile = speakerProfiles[activeSpeakerId]; + if (spkProfile) { + if (spkProfile.config) { + speakerConfig = spkProfile.config; + if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig; + } + if (spkProfile.channels) { + spkProfile.channels.forEach((saved) => { + if (speakerChannels[saved.id]) { + speakerChannels[saved.id].measurement = saved.measurement || null; + speakerChannels[saved.id].targetId = saved.targetId || 'harman_room'; + speakerChannels[saved.id].preamp = saved.preamp || 0; + speakerChannels[saved.id].bands = saved.bands + ? saved.bands.map((b) => ({ ...b })) + : speakerChannels[saved.id].bands; + } + }); + } + speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0]; + } + } + + // Restore EQ mode on startup + const savedEQMode = localStorage.getItem(EQ_MODE_KEY); + if (savedEQMode && ['autoeq', 'parametric', 'speaker'].includes(savedEQMode)) { + setEQMode(savedEQMode); + } + // Now Playing Mode const nowPlayingMode = document.getElementById('now-playing-mode'); if (nowPlayingMode) { @@ -3740,8 +5970,6 @@ function initializeBlockedContentManager() { e.stopPropagation(); const id = btn.dataset.id; const type = btn.dataset.type; - const itemLi = btn.closest('li'); - const itemName = itemLi ? itemLi.querySelector('.item-name').textContent : 'item'; if (type === 'artist') { contentBlockingSettings.unblockArtist(id); @@ -3751,10 +5979,6 @@ function initializeBlockedContentManager() { contentBlockingSettings.unblockTrack(id); } - if (typeof showNotification === 'function') { - showNotification(`Unblocked ${type}: ${itemName}`); - } - renderBlockedLists(); }); }); diff --git a/js/storage.js b/js/storage.js index e7b2035..f2097b4 100644 --- a/js/storage.js +++ b/js/storage.js @@ -999,39 +999,11 @@ export const visualizerSettings = { }, }; -export const playbackSettings = { - FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt', - PRELOAD_TIME_KEY: 'playback-preload-time', - - isFullscreenTiltEnabled() { - try { - return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false'; - } catch { - return true; - } - }, - - setFullscreenTiltEnabled(enabled) { - localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false'); - }, - - getPreloadTime() { - try { - const val = localStorage.getItem(this.PRELOAD_TIME_KEY); - return val ? parseInt(val, 10) : 15; - } catch { - return 15; - } - }, - - setPreloadTime(seconds) { - localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString()); - }, -}; - export const equalizerSettings = { ENABLED_KEY: 'equalizer-enabled', GAINS_KEY: 'equalizer-gains', + BAND_TYPES_KEY: 'equalizer-band-types', + BAND_QS_KEY: 'equalizer-band-qs', PRESET_KEY: 'equalizer-preset', CUSTOM_PRESETS_KEY: 'equalizer-custom-presets', BAND_COUNT_KEY: 'equalizer-band-count', @@ -1308,6 +1280,62 @@ export const equalizerSettings = { } }, + getBandTypes(bandCount) { + const count = bandCount || this.getBandCount(); + try { + const stored = localStorage.getItem(this.BAND_TYPES_KEY); + if (stored) { + const types = JSON.parse(stored); + if (Array.isArray(types) && types.length === count) { + return types; + } + } + } catch { + /* ignore */ + } + return new Array(count).fill('peaking'); + }, + + setBandTypes(types) { + try { + if (Array.isArray(types) && types.length >= this.MIN_BANDS && types.length <= this.MAX_BANDS) { + localStorage.setItem(this.BAND_TYPES_KEY, JSON.stringify(types)); + } + } catch (e) { + console.warn('[EQ] Failed to save band types:', e); + } + }, + + getBandQs(bandCount) { + const count = bandCount || this.getBandCount(); + try { + const stored = localStorage.getItem(this.BAND_QS_KEY); + if (stored) { + const qs = JSON.parse(stored); + if (Array.isArray(qs) && qs.length === count) { + return qs; + } + // Interpolate stored Qs to match requested band count instead of discarding + if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) { + return this._interpolateGains(qs, count); + } + } + } catch { + /* ignore */ + } + return null; + }, + + setBandQs(qs) { + try { + if (Array.isArray(qs) && qs.length >= this.MIN_BANDS && qs.length <= this.MAX_BANDS) { + localStorage.setItem(this.BAND_QS_KEY, JSON.stringify(qs)); + } + } catch (e) { + console.warn('[EQ] Failed to save band Qs:', e); + } + }, + /** * Interpolate gains array to match target band count */ @@ -1440,6 +1468,130 @@ export const equalizerSettings = { return false; } }, + + // ======================================== + // AutoEQ Profile Storage + // ======================================== + AUTOEQ_PROFILES_KEY: 'autoeq-saved-profiles', + AUTOEQ_ACTIVE_PROFILE_KEY: 'autoeq-active-profile', + AUTOEQ_SAMPLE_RATE_KEY: 'autoeq-sample-rate', + + getAutoEQProfiles() { + try { + const stored = localStorage.getItem(this.AUTOEQ_PROFILES_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } + }, + + saveAutoEQProfile(profile) { + try { + const profiles = this.getAutoEQProfiles(); + const id = profile.id || 'autoeq_' + Date.now(); + const profileCopy = { ...profile, id }; + profiles[id] = profileCopy; + localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles)); + return id; + } catch (e) { + console.warn('[AutoEQ] Failed to save profile:', e); + return false; + } + }, + + deleteAutoEQProfile(profileId) { + try { + const profiles = this.getAutoEQProfiles(); + if (profiles[profileId]) { + delete profiles[profileId]; + localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles)); + if (this.getActiveAutoEQProfile() === profileId) { + localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY); + } + return true; + } + return false; + } catch (e) { + console.warn('[AutoEQ] Failed to delete profile:', e); + return false; + } + }, + + getActiveAutoEQProfile() { + try { + return localStorage.getItem(this.AUTOEQ_ACTIVE_PROFILE_KEY) || null; + } catch { + return null; + } + }, + + setActiveAutoEQProfile(profileId) { + if (profileId) { + localStorage.setItem(this.AUTOEQ_ACTIVE_PROFILE_KEY, profileId); + } else { + localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY); + } + }, + + getSampleRate() { + try { + const stored = localStorage.getItem(this.AUTOEQ_SAMPLE_RATE_KEY); + const val = parseInt(stored, 10); + return [44100, 48000, 96000].includes(val) ? val : 48000; + } catch { + return 48000; + } + }, + + setSampleRate(rate) { + localStorage.setItem(this.AUTOEQ_SAMPLE_RATE_KEY, rate.toString()); + }, + + // ======================================== + // Last Selected Headphone Persistence + // ======================================== + AUTOEQ_LAST_HEADPHONE_KEY: 'autoeq-last-headphone', + + /** + * Save the last selected headphone entry + its measurement data + * so it persists across page reloads without re-fetching from GitHub + * @param {object} entry - {name, type, path, fileName} + * @param {Array} measurementData - [{freq, gain}, ...] + */ + setLastHeadphone(entry, measurementData) { + try { + localStorage.setItem( + this.AUTOEQ_LAST_HEADPHONE_KEY, + JSON.stringify({ + entry, + measurementData, + savedAt: Date.now(), + }) + ); + } catch (e) { + console.warn('[AutoEQ] Failed to save last headphone:', e); + } + }, + + /** + * Retrieve the last selected headphone entry + cached measurement data + * @returns {{entry: object, measurementData: Array}|null} + */ + getLastHeadphone() { + try { + const stored = localStorage.getItem(this.AUTOEQ_LAST_HEADPHONE_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored); + if (parsed && parsed.entry && parsed.measurementData) return parsed; + return null; + } catch { + return null; + } + }, + + clearLastHeadphone() { + localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY); + }, }; export const monoAudioSettings = { @@ -2573,7 +2725,7 @@ export const contentBlockingSettings = { isArtistBlocked(artistId) { if (!artistId) return false; - return this.getBlockedArtists().some((a) => a.id == artistId); + return this.getBlockedArtists().some((a) => String(a.id) === String(artistId)); }, blockArtist(artist) { @@ -2590,7 +2742,7 @@ export const contentBlockingSettings = { }, unblockArtist(artistId) { - const blocked = this.getBlockedArtists().filter((a) => a.id != artistId); + const blocked = this.getBlockedArtists().filter((a) => a.id !== artistId); this.setBlockedArtists(blocked); }, @@ -2610,13 +2762,13 @@ export const contentBlockingSettings = { isTrackBlocked(trackId) { if (!trackId) return false; - return this.getBlockedTracks().some((t) => t.id == trackId); + return this.getBlockedTracks().some((t) => t.id === trackId); }, blockTrack(track) { if (!track || !track.id) return; const blocked = this.getBlockedTracks(); - if (!blocked.some((t) => t.id == track.id)) { + if (!blocked.some((t) => t.id === track.id)) { blocked.push({ id: track.id, title: track.title || 'Unknown Track', @@ -2628,7 +2780,7 @@ export const contentBlockingSettings = { }, unblockTrack(trackId) { - const blocked = this.getBlockedTracks().filter((t) => t.id != trackId); + const blocked = this.getBlockedTracks().filter((t) => t.id !== trackId); this.setBlockedTracks(blocked); }, @@ -2648,13 +2800,13 @@ export const contentBlockingSettings = { isAlbumBlocked(albumId) { if (!albumId) return false; - return this.getBlockedAlbums().some((a) => a.id == albumId); + return this.getBlockedAlbums().some((a) => a.id === albumId); }, blockAlbum(album) { if (!album || !album.id) return; const blocked = this.getBlockedAlbums(); - if (!blocked.some((a) => a.id == album.id)) { + if (!blocked.some((a) => a.id === album.id)) { blocked.push({ id: album.id, title: album.title || 'Unknown Album', @@ -2666,7 +2818,7 @@ export const contentBlockingSettings = { }, unblockAlbum(albumId) { - const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId); + const blocked = this.getBlockedAlbums().filter((a) => a.id !== albumId); this.setBlockedAlbums(blocked); }, diff --git a/js/ui.js b/js/ui.js index 4eb9f6f..1855ee5 100644 --- a/js/ui.js +++ b/js/ui.js @@ -26,7 +26,6 @@ import { fontSettings, contentBlockingSettings, settingsUiState, - playbackSettings, } from './storage.js'; import { db } from './db.js'; import { getVibrantColorFromImage } from './vibrant-color.js'; @@ -151,9 +150,6 @@ export class UIRenderer { this.lastRecommendedTracks = []; this.currentArtistId = null; - this._handleTiltMove = this._handleTiltMove.bind(this); - this._handleTiltLeave = this._handleTiltLeave.bind(this); - // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { this.resetVibrantColor(); @@ -1231,14 +1227,6 @@ export class UIRenderer { overlay.style.display = 'flex'; - // Apply vanilla-tilt effect to fullscreen cover if enabled - this._applyFullscreenTilt(overlay); - - // Listen for tilt setting changes - window.addEventListener('fullscreen-tilt-toggle', (e) => { - this._applyFullscreenTilt(overlay, e.detail.enabled); - }); - const startVisualizer = async () => { if (!visualizerSettings.isEnabled()) { if (this.visualizer) this.visualizer.stop(); @@ -1332,46 +1320,6 @@ export class UIRenderer { clearTimeout(this.uiToggleMouseTimer); this.uiToggleMouseTimer = null; } - - // Clean up vanilla-tilt if applied - this._removeFullscreenTilt(); - } - - _applyFullscreenTilt(overlay, enabled = playbackSettings.isFullscreenTiltEnabled()) { - const image = document.getElementById('fullscreen-cover-image'); - if (!image) return; - - this._removeFullscreenTilt(); - - if (!enabled) return; - - image.addEventListener('mousemove', this._handleTiltMove); - image.addEventListener('mouseleave', this._handleTiltLeave); - } - - _handleTiltMove(e) { - const image = e.target; - const rect = image.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const centerX = rect.width / 2; - const centerY = rect.height / 2; - const rotateX = ((y - centerY) / centerY) * -10; - const rotateY = ((x - centerX) / centerX) * 10; - - image.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`; - } - - _handleTiltLeave(e) { - e.target.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) scale(1)'; - } - - _removeFullscreenTilt() { - const image = document.getElementById('fullscreen-cover-image'); - if (!image) return; - image.removeEventListener('mousemove', this._handleTiltMove); - image.removeEventListener('mouseleave', this._handleTiltLeave); - image.style.transform = ''; } setupUIToggleButton(overlay) { diff --git a/styles.css b/styles.css index 11cc31b..2150620 100644 --- a/styles.css +++ b/styles.css @@ -7712,6 +7712,9 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { transform: scale(0.97); } +/* ======================================== + Precision AutoEQ - Redesigned Equalizer + ======================================== */ .equalizer-container { margin-top: var(--spacing-md); padding: var(--spacing-lg); @@ -7719,330 +7722,1043 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: inset 0 1px 1px rgb(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + gap: var(--spacing-lg); } -.equalizer-header { - margin-bottom: var(--spacing-lg); -} - -.equalizer-preset-row { +/* --- Mode Toggle --- */ +.autoeq-mode-row { display: flex; align-items: center; gap: var(--spacing-sm); +} + +.autoeq-mode-toggle { + display: flex; + flex: 1; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.eq-howto-btn { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--input); + color: var(--muted-foreground); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.eq-howto-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: rgb(var(--highlight-rgb), 0.08); +} + +.eq-howto-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgb(var(--highlight-rgb), 0.03); + padding: var(--spacing-md); + position: relative; + animation: fade-slide-in 0.2s ease; +} + +@keyframes fade-slide-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.eq-howto-close { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--muted-foreground); + font-size: 1.2rem; + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; +} + +.eq-howto-close:hover { + color: var(--foreground); + background: rgb(var(--highlight-rgb), 0.1); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.eq-howto-tab h4 { + font-size: 0.85rem; + font-weight: 700; + color: var(--foreground); + margin: 0 0 var(--spacing-sm) 0; +} + +.eq-howto-tab ol { + margin: 0; + padding-left: 1.4em; + font-size: 0.78rem; + color: var(--muted-foreground); + line-height: 1.7; +} + +.eq-howto-tab ol b { + color: var(--foreground); +} + +.eq-howto-tip { + margin: var(--spacing-sm) 0 0; + padding: var(--spacing-xs) var(--spacing-sm); + background: rgb(var(--highlight-rgb), 0.06); + border-radius: var(--radius-sm); + font-size: 0.72rem; + color: var(--primary); + font-weight: 500; +} + +.autoeq-mode-btn { + flex: 1; + padding: 0.5rem 1rem; + font-size: 0.8rem; + font-weight: 600; + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + transition: all var(--transition-fast); + text-align: center; +} + +.autoeq-mode-btn:hover { + color: var(--foreground); +} + +.autoeq-mode-btn.active { + background: var(--primary); + color: var(--primary-foreground); +} + +/* --- Graph Section --- */ +.autoeq-graph-section { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.autoeq-graph-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.autoeq-graph-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.autoeq-graph-legend { + display: flex; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: 0.7rem; + color: var(--muted-foreground); +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.legend-original .legend-dot { + background: #3b82f6; +} + +.legend-target .legend-dot { + background: rgb(255 255 255 / 0.5); + border: 1px dashed rgb(255 255 255 / 0.3); +} +.legend-corrected .legend-dot { + background: #f472b6; +} + +.autoeq-graph-wrapper { + position: relative; + width: 100%; + height: 300px; + background: var(--background); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.autoeq-response-canvas { + display: block; + width: 100%; + height: 100%; + cursor: crosshair; +} + +.autoeq-auto-preamp { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-xs) 0; +} + +.autoeq-auto-preamp-label { + font-size: 0.75rem; + color: var(--muted-foreground); + cursor: pointer; + user-select: none; +} + +.toggle-switch-sm { + transform: scale(0.75); + transform-origin: right center; +} + +/* --- Controls Section --- */ +.autoeq-controls-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.autoeq-control-group { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.autoeq-control-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--primary); + font-weight: 600; +} + +.autoeq-select-wrapper { + display: flex; + gap: var(--spacing-xs); +} + +.autoeq-select-wrapper select { + flex: 1; + padding: 0.6rem 0.75rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.85rem; + cursor: pointer; + transition: border-color var(--transition-fast); +} + +.autoeq-select-wrapper select:hover { + border-color: var(--primary); +} + +.autoeq-select-wrapper select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); +} + +.autoeq-settings-btn { + width: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted-foreground); + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.autoeq-settings-btn:hover { + border-color: var(--primary); + color: var(--foreground); +} + +.autoeq-controls-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--spacing-sm); +} + +.autoeq-control-mini { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-control-mini select { + padding: 0.6rem 0.5rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.85rem; + cursor: pointer; + transition: border-color var(--transition-fast); +} + +.autoeq-control-mini select:hover { + border-color: var(--primary); +} + +.autoeq-control-mini select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); +} + +.autoeq-actions-row { + display: flex; + gap: var(--spacing-sm); + align-items: stretch; +} + +.autoeq-download-btn { + width: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted-foreground); + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.autoeq-download-btn:hover { + border-color: var(--primary); + color: var(--foreground); +} + +.autoeq-run-btn { + flex: 1; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 700; + background: var(--primary); + border: none; + border-radius: var(--radius); + color: var(--primary-foreground); + cursor: pointer; + transition: all var(--transition-fast); + letter-spacing: 0.02em; +} + +.autoeq-run-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.autoeq-run-btn:hover:not(:disabled) { + filter: brightness(1.1); + box-shadow: 0 4px 16px rgb(var(--highlight-rgb), 0.3); +} + +.autoeq-status { + font-size: 0.75rem; + color: var(--muted-foreground); + font-style: italic; +} + +.autoeq-status.error { + color: var(--destructive); +} +.autoeq-status.success { + color: var(--primary); +} + +/* --- Saved Profiles --- */ +.autoeq-saved-section { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.autoeq-saved-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-md); + background: rgb(var(--highlight-rgb), 0.04); + gap: var(--spacing-sm); flex-wrap: wrap; } -.equalizer-preset-row label { - font-size: 0.9rem; +.autoeq-saved-header-left { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.autoeq-saved-title { + font-size: 0.75rem; + font-weight: 700; + color: var(--primary); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.autoeq-saved-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 0.65rem; + font-weight: 700; + background: var(--primary); + color: var(--primary-foreground); + border-radius: var(--radius-full); +} + +.autoeq-saved-header-right { + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.autoeq-profile-name-input { + width: 140px; + padding: 0.35rem 0.6rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.8rem; + transition: border-color var(--transition-fast); +} + +.autoeq-profile-name-input:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); +} + +.autoeq-save-btn { + padding: 0.35rem 0.75rem; + font-size: 0.8rem; +} + +.autoeq-collapse-btn { + background: none; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + transition: transform var(--transition-fast); +} + +.autoeq-collapse-btn.collapsed { + transform: rotate(180deg); +} + +.autoeq-saved-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.autoeq-profile-card { + display: flex; + flex-direction: column; + padding: 0; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius-lg, 12px); + cursor: pointer; + transition: + background var(--transition-fast), + border-color var(--transition-fast); + position: relative; + overflow: hidden; +} + +.autoeq-profile-card:last-child { + border-bottom: 1px solid var(--border); +} + +.autoeq-profile-card:hover { + border-color: var(--primary); + background: rgb(var(--highlight-rgb), 0.06); +} + +.autoeq-profile-card.active { + border-color: var(--primary); + background: rgb(var(--highlight-rgb), 0.08); +} + +.autoeq-profile-preview { + width: 100%; + height: 100px; + display: block; + background: rgb(0 0 0 / 0.25); +} + +.autoeq-profile-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); +} + +.autoeq-profile-active-icon { + display: none; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--primary); + color: var(--primary-foreground); + font-size: 0.7rem; + font-weight: 700; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.autoeq-profile-card.active .autoeq-profile-active-icon { + display: flex; +} + +.autoeq-profile-name { + font-size: 0.95rem; + font-weight: 600; + color: var(--foreground); +} + +.autoeq-profile-meta { + font-size: 0.75rem; + color: var(--muted-foreground); + width: 100%; + padding-left: 0; + margin-left: 0; +} + +.autoeq-profile-card.active .autoeq-profile-meta { + padding-left: calc(22px + var(--spacing-sm)); +} + +.autoeq-profile-delete { + position: absolute; + bottom: var(--spacing-sm); + right: var(--spacing-sm); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgb(var(--highlight-rgb), 0.1); + border: none; + border-radius: var(--radius); + color: var(--muted-foreground); + cursor: pointer; + opacity: 0; + transition: all var(--transition-fast); + font-size: 1rem; +} + +.autoeq-profile-delete:hover { + background: var(--destructive); + color: var(--destructive-foreground); +} + +.autoeq-profile-card:hover .autoeq-profile-delete { + opacity: 1; +} + +/* --- Database Browser --- */ +.autoeq-database-section { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.autoeq-database-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); +} + +.autoeq-database-title { + font-size: 1rem; + font-weight: 700; + color: var(--foreground); + margin: 0; +} + +.autoeq-database-subtitle { + font-size: 0.7rem; + color: var(--muted-foreground); + display: block; +} + +.autoeq-database-count { + font-size: 0.75rem; color: var(--muted-foreground); font-weight: 500; } -.equalizer-preset-row select { - flex: 1; - min-width: 150px; - max-width: 250px; - padding: 0.5rem 1rem; - background: var(--input); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); - font-size: 0.9rem; - cursor: pointer; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.equalizer-preset-row select:hover { - border-color: var(--primary); -} - -.equalizer-preset-row select:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); -} - -.eq-band-count-input { - width: 60px; - padding: 0.5rem; - background: var(--input); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); - font-size: 0.9rem; - text-align: center; - cursor: pointer; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.eq-band-count-input:hover { - border-color: var(--primary); -} - -.eq-band-count-input:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); -} - -/* Hide number input arrows */ -.eq-band-count-input::-webkit-outer-spin-button, -.eq-band-count-input::-webkit-inner-spin-button { - appearance: none; - margin: 0; -} - -.eq-band-count-input[type='number'] { - appearance: textfield; -} - -#equalizer-reset-btn { - padding: 0.5rem; +.autoeq-database-search { display: flex; align-items: center; - justify-content: center; - border-radius: var(--radius); - background: var(--input); - border: 1px solid var(--border); - color: var(--muted-foreground); - transition: all var(--transition-fast); -} - -#equalizer-reset-btn:hover { - background: var(--card); - border-color: var(--primary); - color: var(--foreground); -} - -#equalizer-reset-btn svg { - transition: transform 0.3s ease; -} - -#equalizer-reset-btn:hover svg { - transform: rotate(-45deg); -} - -/* Custom Preset Controls */ -.custom-preset-controls { - margin-top: var(--spacing-md); - padding-top: var(--spacing-md); - border-top: 1px solid var(--border); -} - -.custom-preset-input-row { - display: flex; gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); + padding: 0 var(--spacing-md) var(--spacing-md); } -#custom-preset-name { +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-database-search svg { + color: var(--muted-foreground); + flex-shrink: 0; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-database-search input { flex: 1; padding: 0.5rem 0.75rem; background: var(--input); border: 1px solid var(--border); border-radius: var(--radius); color: var(--foreground); - font-size: 0.9rem; + font-size: 0.85rem; transition: border-color var(--transition-fast); } -#custom-preset-name:focus { +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-database-search input:focus { outline: none; border-color: var(--ring); box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); } -#save-custom-preset-btn { +.autoeq-database-content { + display: flex; + position: relative; +} + +.autoeq-database-list { + flex: 1; + max-height: 400px; + overflow-y: auto; +} + +.autoeq-database-alpha-index { + width: 22px; + display: flex; + flex-direction: column; + align-items: center; + padding: 2px 0; + flex-shrink: 0; + position: sticky; + top: 0; + align-self: flex-start; + max-height: 400px; + overflow: hidden; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-database-alpha-index button { + width: 18px; + height: 16px; + font-size: 0.5rem; + font-weight: 700; display: flex; align-items: center; - gap: var(--spacing-xs); - padding: 0.5rem 1rem; + justify-content: center; + color: var(--muted-foreground); + background: none; + border: none; + cursor: pointer; + border-radius: var(--radius-sm); + padding: 0; + line-height: 1; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-database-alpha-index button:hover { + color: var(--primary); + background: rgb(var(--highlight-rgb), 0.1); +} + +.autoeq-db-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-top: 1px solid rgb(var(--highlight-rgb), 0.05); + cursor: pointer; + transition: background var(--transition-fast); +} + +.autoeq-db-item:hover { + background: rgb(var(--highlight-rgb), 0.08); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-db-item svg { + color: var(--muted-foreground); + flex-shrink: 0; +} + +.autoeq-db-item-info { + flex: 1; + min-width: 0; +} + +.autoeq-db-item-name { + font-size: 0.85rem; + font-weight: 500; + color: var(--primary); + display: block; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -#save-custom-preset-btn svg { +.autoeq-db-item-meta { + font-size: 0.7rem; + color: var(--muted-foreground); + display: block; +} + +.autoeq-db-item-chevron { + color: var(--muted-foreground); flex-shrink: 0; + opacity: 0.5; + transition: transform var(--transition-fast); } -.delete-preset-btn { - display: flex; - align-items: center; - gap: var(--spacing-xs); - padding: 0.5rem 1rem; - font-size: 0.85rem; - color: var(--destructive); - border-color: var(--destructive); - opacity: 0.8; - transition: opacity var(--transition-fast); +.autoeq-db-item.expanded .autoeq-db-item-chevron { + transform: rotate(90deg); } -.delete-preset-btn:hover { - opacity: 1; - background: var(--destructive); - color: var(--destructive-foreground); +.autoeq-db-sub-list { + display: none; + padding-left: var(--spacing-lg); + background: rgb(var(--highlight-rgb), 0.02); } -.delete-preset-btn svg { - flex-shrink: 0; +.autoeq-db-sub-list.visible { + display: block; } -/* EQ Range Controls */ -.eq-range-controls { +.autoeq-db-sub-item { display: flex; align-items: center; gap: var(--spacing-sm); - margin-top: var(--spacing-sm); - padding-top: var(--spacing-sm); - border-top: 1px solid var(--border); - flex-wrap: wrap; -} - -.eq-range-controls label { - font-size: 0.9rem; + padding: var(--spacing-xs) var(--spacing-md); + border-top: 1px solid rgb(var(--highlight-rgb), 0.03); + cursor: pointer; + font-size: 0.8rem; color: var(--muted-foreground); - font-weight: 500; + transition: all var(--transition-fast); } -.eq-range-controls span { - font-size: 0.9rem; +.autoeq-db-sub-item:hover { + background: rgb(var(--highlight-rgb), 0.08); + color: var(--foreground); +} + +.autoeq-db-sub-item .sub-source { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: var(--radius-sm); + background: rgb(var(--highlight-rgb), 0.08); color: var(--muted-foreground); } -.eq-range-input { - width: 60px; - padding: 0.4rem 0.5rem; - background: var(--input); +.autoeq-database-list::-webkit-scrollbar { + width: 6px; +} + +.autoeq-database-list::-webkit-scrollbar-track { + background: transparent; +} + +.autoeq-database-list::-webkit-scrollbar-thumb { + background: rgb(var(--highlight-rgb), 0.2); + border-radius: 3px; +} + +.autoeq-database-list::-webkit-scrollbar-thumb:hover { + background: rgb(var(--highlight-rgb), 0.4); +} + +/* --- Parametric EQ Filters Section --- */ +.autoeq-filters-section { border: 1px solid var(--border); border-radius: var(--radius); - color: var(--foreground); - font-size: 0.9rem; - text-align: center; - transition: border-color var(--transition-fast); + overflow: hidden; } -.eq-range-input:hover { - border-color: var(--primary); -} - -.eq-range-input:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); -} - -/* Hide number input arrows */ -.eq-range-input::-webkit-outer-spin-button, -.eq-range-input::-webkit-inner-spin-button { - appearance: none; - margin: 0; -} - -.eq-range-input[type='number'] { - appearance: textfield; -} - -#apply-eq-range-btn { - padding: 0.4rem 0.75rem; - font-size: 0.85rem; -} - -#reset-eq-range-btn { - padding: 0.4rem 0.75rem; - font-size: 0.85rem; - margin-left: var(--spacing-xs); -} - -/* EQ Frequency Range Controls */ -.eq-freq-controls { +.autoeq-filters-header { display: flex; align-items: center; - gap: var(--spacing-sm); - margin-top: var(--spacing-sm); - padding-top: var(--spacing-sm); - border-top: 1px solid var(--border); - flex-wrap: wrap; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-md); + background: rgb(var(--highlight-rgb), 0.04); + cursor: pointer; + user-select: none; + font-size: 0.75rem; + font-weight: 700; + color: var(--primary); + text-transform: uppercase; + letter-spacing: 0.08em; + transition: background var(--transition-fast); } -.eq-freq-controls label { - font-size: 0.9rem; - color: var(--muted-foreground); - font-weight: 500; +.autoeq-filters-header:hover { + background: rgb(var(--highlight-rgb), 0.08); } -.eq-freq-controls span { - font-size: 0.9rem; - color: var(--muted-foreground); -} - -.eq-freq-input { - width: 70px; - padding: 0.4rem 0.5rem; - background: var(--input); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); - font-size: 0.9rem; - text-align: center; - transition: border-color var(--transition-fast); -} - -.eq-freq-input:hover { - border-color: var(--primary); -} - -.eq-freq-input:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); -} - -/* Hide number input arrows */ -.eq-freq-input::-webkit-outer-spin-button, -.eq-freq-input::-webkit-inner-spin-button { - appearance: none; - margin: 0; -} - -.eq-freq-input[type='number'] { - appearance: textfield; -} - -#apply-eq-freq-btn { - padding: 0.4rem 0.75rem; - font-size: 0.85rem; -} - -#reset-eq-freq-btn { - padding: 0.4rem 0.75rem; - font-size: 0.85rem; - margin-left: var(--spacing-xs); -} - -/* EQ Preamp Controls */ -.eq-preamp-controls { +.autoeq-filters-content { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.autoeq-preset-row { display: flex; - align-items: center; gap: var(--spacing-sm); - margin-top: var(--spacing-sm); - padding-top: var(--spacing-sm); - border-top: 1px solid var(--border); - flex-wrap: wrap; } -.eq-preamp-controls label { - font-size: 0.9rem; - color: var(--muted-foreground); - font-weight: 500; -} - -#eq-preamp-slider { +.autoeq-preset-row .autoeq-control-group { + flex: 1; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-preset-row select { + width: 100%; + padding: 0.6rem 0.75rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.85rem; + cursor: pointer; + transition: border-color var(--transition-fast); +} + +.autoeq-preset-row select:hover { + border-color: var(--primary); +} + +.autoeq-preset-row select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2); +} + +.autoeq-parametric-profiles { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.autoeq-parametric-profiles .autoeq-saved-header { + padding: var(--spacing-xs) var(--spacing-md); +} + +.autoeq-filters-actions { + display: flex; + gap: var(--spacing-xs); + flex-wrap: wrap; + align-items: center; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.autoeq-filters-actions button { + padding: 0.35rem 0.75rem; + font-size: 0.75rem; +} + +.autoeq-preamp-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.autoeq-preamp-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--foreground); + min-width: 55px; +} + +.autoeq-preamp-slider { + flex: 1; + height: 6px; + appearance: none; + background: var(--border); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.autoeq-preamp-slider::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + transition: transform var(--transition-fast); +} + +.autoeq-preamp-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.autoeq-preamp-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + border: none; +} + +.autoeq-preamp-value { + font-size: 0.8rem; + font-weight: 600; + color: var(--primary); + min-width: 50px; + text-align: right; +} + +.autoeq-bands-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.autoeq-band-control { + padding: 0.4rem 0.6rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.autoeq-band-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.autoeq-band-number { + font-size: 0.65rem; + font-weight: 700; + color: var(--muted-foreground); + min-width: 1rem; + text-align: center; + opacity: 0.6; +} + +.autoeq-type-select { + padding: 0.15rem 0.3rem; + font-size: 0.7rem; + font-weight: 600; + background: var(--background); + color: var(--primary); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + outline: none; + flex-shrink: 0; +} + +.autoeq-type-select:hover { + border-color: var(--primary); +} + +.autoeq-type-select:focus { + border-color: var(--ring); +} + +.autoeq-band-param { + display: flex; + align-items: baseline; + gap: 0.25rem; + flex: 1; + justify-content: center; +} + +.autoeq-band-param-label { + font-size: 0.65rem; + color: var(--muted-foreground); + font-weight: 500; + text-transform: uppercase; + opacity: 0.7; +} + +.autoeq-band-value { + font-size: 0.75rem; + font-weight: 600; + color: var(--primary); + white-space: nowrap; +} + +.autoeq-band-sliders { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.autoeq-band-slider { flex: 1; - min-width: 120px; - max-width: 200px; height: 4px; appearance: none; background: var(--border); @@ -8051,317 +8767,281 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { cursor: pointer; } -#eq-preamp-slider::-webkit-slider-thumb { +.autoeq-band-slider::-webkit-slider-thumb { appearance: none; - width: 16px; - height: 16px; + width: 12px; + height: 12px; border-radius: 50%; background: var(--primary); cursor: pointer; transition: transform var(--transition-fast); } -#eq-preamp-slider::-webkit-slider-thumb:hover { - transform: scale(1.2); +.autoeq-band-slider::-webkit-slider-thumb:hover { + transform: scale(1.3); } -#eq-preamp-slider::-moz-range-thumb { - width: 16px; - height: 16px; +.autoeq-band-slider::-moz-range-thumb { + width: 12px; + height: 12px; border-radius: 50%; background: var(--primary); cursor: pointer; border: none; - transition: transform var(--transition-fast); } -#eq-preamp-slider::-moz-range-thumb:hover { - transform: scale(1.2); +/* --- Speaker EQ --- */ +.speaker-eq-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md); } -/* EQ Import/Export Buttons (now inline) */ -#eq-export-btn, -#eq-import-btn { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; +.speaker-eq-header { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.speaker-eq-config-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.speaker-config-select { + padding: 0.5rem 0.75rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.speaker-config-select:hover { + border-color: var(--primary); +} + +.speaker-channel-tabs { + display: flex; + gap: 2px; + overflow-x: auto; + padding-bottom: 2px; +} + +.speaker-channel-tab { + padding: 0.4rem 0.8rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--muted-foreground); + cursor: pointer; + transition: all var(--transition-fast); + position: relative; +} + +.speaker-channel-tab:hover { + border-color: var(--primary); + color: var(--foreground); +} + +.speaker-channel-tab.active { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-foreground); +} + +.speaker-channel-tab.has-data::after { + content: ''; + position: absolute; + top: 3px; + right: 3px; + width: 5px; + height: 5px; + border-radius: 50%; + background: #4ade80; +} + +.speaker-channel-tab.active.has-data::after { + background: var(--primary-foreground); +} + +.speaker-eq-controls { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background: rgb(var(--highlight-rgb), 0.03); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.speaker-eq-measurement-row { + display: flex; + gap: var(--spacing-sm); + align-items: flex-end; + flex-wrap: wrap; +} + +.speaker-measurement-status { + flex: 1; + font-size: 0.8rem; + color: var(--muted-foreground); + padding: 0.5rem 0.75rem; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; +} + +.speaker-measurement-status.loaded { + color: #4ade80; + border-color: rgb(74 222 128 / 0.3); +} + +.speaker-eq-params-row { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + align-items: flex-end; +} + +.speaker-eq-slider-control { + flex: 1; + min-width: 120px; +} + +.speaker-eq-slider-row { + display: flex; + align-items: center; gap: var(--spacing-xs); } -/* Equalizer preset dropdown styling */ -.equalizer-preset-row select optgroup { - font-weight: 600; - color: var(--foreground); - padding: var(--spacing-xs) 0; -} - -.equalizer-preset-row select optgroup option { - font-weight: 400; - padding-left: var(--spacing-sm); -} - -.equalizer-bands-wrapper { - position: relative; - padding: var(--spacing-md) 0; -} - -.equalizer-bands { - display: flex; - justify-content: space-between; - gap: 4px; - position: relative; -} - -/* Zero line indicator - positioned at center of slider tracks */ -.equalizer-bands::before { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 60px; - height: 1px; - background: var(--border); - opacity: 0.5; - pointer-events: none; - z-index: 0; -} - -/* EQ Response Curve Canvas */ -.eq-response-canvas { - position: absolute; - top: var(--spacing-md); - left: 4px; - width: calc(100% - 8px); - height: 120px; - pointer-events: none; - z-index: 2; - display: block; -} - -.eq-band { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; +.speaker-eq-slider-row input[type='range'] { flex: 1; - min-width: 0; - position: relative; - z-index: 1; - cursor: ns-resize; - user-select: none; } -/* Vertical slider styling */ -.eq-slider { - appearance: none; - writing-mode: vertical-lr; - direction: rtl; - width: 8px; - height: 120px; - background: transparent; - cursor: pointer; - user-select: none; - position: relative; -} - -/* Track */ -.eq-slider::-webkit-slider-runnable-track { - width: 6px; - height: 100%; - background: linear-gradient(to top, var(--muted), var(--input)); - border-radius: var(--radius-full); - border: 1px solid var(--border); -} - -.eq-slider::-moz-range-track { - width: 6px; - height: 100%; - background: linear-gradient(to top, var(--muted), var(--input)); - border-radius: var(--radius-full); - border: 1px solid var(--border); -} - -/* Thumb */ -.eq-slider::-webkit-slider-thumb { - appearance: none; - width: 18px; - height: 18px; - background: linear-gradient(145deg, var(--primary), var(--highlight)); - border-radius: var(--radius-full); - cursor: grab; - margin-left: -6px; - box-shadow: - 0 2px 8px rgb(0, 0, 0, 0.3), - inset 0 1px 2px rgb(255, 255, 255, 0.3); - transition: - transform 0.15s ease, - box-shadow 0.15s ease; - border: 2px solid var(--background); -} - -.eq-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: linear-gradient(145deg, var(--primary), var(--highlight)); - border-radius: var(--radius-full); - cursor: grab; - box-shadow: - 0 2px 8px rgb(0, 0, 0, 0.3), - inset 0 1px 2px rgb(255, 255, 255, 0.3); - transition: - transform 0.15s ease, - box-shadow 0.15s ease; - border: 2px solid var(--background); -} - -.eq-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: - 0 4px 12px rgb(var(--highlight-rgb), 0.4), - inset 0 1px 2px rgb(255, 255, 255, 0.3); -} - -.eq-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: - 0 4px 12px rgb(var(--highlight-rgb), 0.4), - inset 0 1px 2px rgb(255, 255, 255, 0.3); -} - -.eq-slider::-webkit-slider-thumb:active { - cursor: grabbing; - transform: scale(1.1); -} - -.eq-slider::-moz-range-thumb:active { - cursor: grabbing; - transform: scale(1.1); -} - -.eq-slider:focus { - outline: none; -} - -.eq-slider:focus::-webkit-slider-thumb { - box-shadow: - 0 0 0 4px rgb(var(--highlight-rgb), 0.3), - 0 2px 8px rgb(0, 0, 0, 0.3); -} - -.eq-slider:focus::-moz-range-thumb { - box-shadow: - 0 0 0 4px rgb(var(--highlight-rgb), 0.3), - 0 2px 8px rgb(0, 0, 0, 0.3); -} - -.eq-value { +.speaker-eq-slider-value { font-size: 0.7rem; font-weight: 600; - color: var(--foreground); - min-width: 28px; - text-align: center; - padding: 2px 4px; - background: var(--input); - border-radius: var(--radius-sm); - transition: - color 0.2s ease, - background 0.2s ease; + color: var(--primary); + min-width: 45px; + text-align: right; } -.eq-value.positive { - color: var(--highlight); - background: rgb(var(--highlight-rgb), 0.15); +.speaker-eq-slider-value.bass { + color: #22d3ee; +} +.speaker-eq-slider-value.room { + color: #f59e0b; +} +.speaker-eq-section .autoeq-control-label.bass { + color: #22d3ee; +} +.speaker-eq-section .autoeq-control-label.room { + color: #f59e0b; } -.eq-value.negative { - color: #ef4444; - background: rgb(239, 68, 68, 0.15); +.speaker-measure-btn { + color: #f472b6; } -.eq-freq { - font-size: 0.65rem; - color: var(--muted-foreground); - text-align: center; - white-space: nowrap; - font-weight: 500; +.speaker-measure-btn:hover { + background: rgb(244 114 182 / 0.15); } -.equalizer-scale { +.speaker-measure-btn:disabled { + opacity: 0.5; + pointer-events: none; +} + +.speaker-eq-actions-row { display: flex; - justify-content: space-between; - padding-top: var(--spacing-sm); - border-top: 1px solid var(--border); + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.speaker-all-btn { + background: var(--surface-2) !important; + color: var(--foreground) !important; + border: 1px solid var(--border); + font-size: 0.75rem; + padding: 0.35rem 0.75rem; + flex: 0 0 auto; + width: auto; +} + +.speaker-all-btn:hover { + background: var(--surface-3) !important; + border-color: var(--primary); +} + +/* Speaker Saved Profiles */ +.speaker-saved-section { margin-top: var(--spacing-sm); } -.equalizer-scale span { - font-size: 0.7rem; - color: var(--muted-foreground); - opacity: 0.7; -} - -/* Responsive adjustments */ +/* --- Responsive --- */ @media (max-width: 768px) { .equalizer-container { padding: var(--spacing-md); } - .equalizer-bands { - gap: 2px; - overflow-x: auto; - padding-bottom: var(--spacing-sm); - -webkit-overflow-scrolling: touch; + .autoeq-graph-wrapper { + height: 220px; } - .eq-band { - min-width: 36px; + .autoeq-controls-row { + grid-template-columns: 1fr 1fr 1fr; } - .eq-slider { - height: 100px; + .autoeq-database-list { + max-height: 300px; + } +} + +@media (max-width: 900px) { + .autoeq-graph-db-row { + flex-direction: column; } - .eq-slider::-webkit-slider-thumb { - width: 16px; - height: 16px; - margin-left: -5px; - } - - .eq-slider::-moz-range-thumb { - width: 16px; - height: 16px; - } - - .eq-freq { - font-size: 0.55rem; - } - - .eq-value { - font-size: 0.6rem; - min-width: 24px; + .autoeq-graph-db-row > .autoeq-database-section { + width: auto; } } @media (max-width: 480px) { - .equalizer-preset-row { + .autoeq-graph-wrapper { + height: 180px; + } + + .autoeq-graph-header { flex-direction: column; - align-items: stretch; + align-items: flex-start; } - .equalizer-preset-row select { - max-width: none; + .autoeq-saved-header { + flex-direction: column; + align-items: flex-start; } - .equalizer-preset-row label { - margin-bottom: -0.5rem; + .autoeq-saved-header-right { + width: 100%; } - .eq-slider { - height: 80px; - } - - .eq-band { - min-width: 30px; + .autoeq-profile-name-input { + flex: 1; + width: auto; } } @@ -8669,6 +9349,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { gap: 0.25rem; } +/* stylelint-disable-next-line no-descending-specificity */ .theme-editor-toolbar select { height: 24px; padding: 0 4px;