-
{
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;