+
+ Bands
+
+ Min Hz
+
+ Max Hz
+
+
Preset {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
- filter.Q.value = 2.5; // constant Q for 16-band
+ filter.Q.value = geqQ;
filter.gain.value = this.geqGains[i] || 0;
return filter;
});
@@ -1136,7 +1139,7 @@ class AudioContextManager {
}
setGraphicEqBandGain(bandIndex, gainDb) {
- if (bandIndex < 0 || bandIndex >= 16) return;
+ if (bandIndex < 0 || bandIndex >= this.geqBandCount) return;
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
if (this.geqFilters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
@@ -1149,7 +1152,7 @@ class AudioContextManager {
if (!Array.isArray(gains)) return;
const now = this.audioContext?.currentTime || 0;
gains.forEach((g, i) => {
- if (i >= 16) return;
+ if (i >= this.geqBandCount) return;
this.geqGains[i] = Math.max(-30, Math.min(30, g));
if (this.geqFilters[i]) {
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
@@ -1158,6 +1161,51 @@ class AudioContextManager {
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
+ setGraphicEqBandCount(count) {
+ const newCount = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
+ if (newCount === this.geqBandCount) return;
+
+ const oldGains = this.geqGains;
+ this.geqBandCount = newCount;
+ this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max);
+ this.geqGains = equalizerSettings._interpolateGains(oldGains, newCount);
+
+ equalizerSettings.setGraphicEqBandCount(newCount);
+ equalizerSettings.setGraphicEqGains(this.geqGains);
+
+ if (this.isInitialized && this.audioContext) {
+ this._destroyGraphicEQ();
+ this._createGraphicEQ();
+ this._connectGraph();
+ }
+ }
+
+ setGraphicEqFreqRange(minFreq, maxFreq) {
+ const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 25));
+ const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
+ if (newMin >= newMax) return;
+ if (newMin === this.geqFreqRange.min && newMax === this.geqFreqRange.max) return;
+
+ this.geqFreqRange = { min: newMin, max: newMax };
+ this.geqFrequencies = generateFrequencies(this.geqBandCount, newMin, newMax);
+
+ equalizerSettings.setGraphicEqFreqRange(newMin, newMax);
+
+ if (this.isInitialized && this.audioContext) {
+ this._destroyGraphicEQ();
+ this._createGraphicEQ();
+ this._connectGraph();
+ }
+ }
+
+ getGraphicEqFrequencies() {
+ return this.geqFrequencies;
+ }
+
+ getGraphicEqBandCount() {
+ return this.geqBandCount;
+ }
+
setGraphicEqPreamp(db) {
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
if (this.geqPreampNode && this.audioContext) {
diff --git a/js/settings.js b/js/settings.js
index 67ee468..babe0b4 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -1236,26 +1236,29 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}
// ========================================
- // 16-Band Graphic Equalizer (Legacy EQ mode)
+ // Graphic Equalizer (Legacy EQ mode) - Configurable Bands
// ========================================
- const GEQ_LABELS = [
- '25',
- '40',
- '63',
- '100',
- '160',
- '250',
- '400',
- '630',
- '1K',
- '1.6K',
- '2.5K',
- '4K',
- '6.3K',
- '10K',
- '16K',
- '20K',
- ];
+ let geqBandCount = equalizerSettings.getGraphicEqBandCount();
+ let geqFreqRange = equalizerSettings.getGraphicEqFreqRange();
+
+ const formatGeqFreq = (freq) => {
+ if (freq >= 10000) return (freq / 1000).toFixed(0) + 'K';
+ if (freq >= 1000) return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'K';
+ return freq.toString();
+ };
+
+ const generateGeqFrequencies = (count, min, max) => {
+ const freqs = [];
+ for (let i = 0; i < count; i++) {
+ const t = i / (count - 1);
+ freqs.push(Math.round(min * Math.pow(max / min, t)));
+ }
+ return freqs;
+ };
+
+ let GEQ_FREQUENCIES = generateGeqFrequencies(geqBandCount, geqFreqRange.min, geqFreqRange.max);
+ let GEQ_LABELS = GEQ_FREQUENCIES.map(formatGeqFreq);
+
const geqBandsContainer = document.getElementById('graphic-eq-bands');
const geqPreampSlider = document.getElementById('graphic-eq-preamp-slider');
const geqPreampValue = document.getElementById('graphic-eq-preamp-value');
@@ -1268,11 +1271,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const legacyGeqPresetSelect = document.getElementById('legacy-graphic-eq-preset-select');
const legacyGeqResetBtn = document.getElementById('legacy-graphic-eq-reset-btn');
+ const geqBandCountInput = document.getElementById('legacy-geq-band-count');
+ const geqFreqMinInput = document.getElementById('legacy-geq-freq-min');
+ const geqFreqMaxInput = document.getElementById('legacy-geq-freq-max');
+
const geqPreampSliders = [geqPreampSlider, legacyGeqPreampSlider].filter(Boolean);
const geqPreampValues = [geqPreampValue, legacyGeqPreampValue].filter(Boolean);
const geqPresetSelects = [geqPresetSelect, legacyGeqPresetSelect].filter(Boolean);
- let geqGains = equalizerSettings.getGraphicEqGains() || new Array(16).fill(0);
+ let geqGains = equalizerSettings.getGraphicEqGains(geqBandCount) || new Array(geqBandCount).fill(0);
let geqPreamp = equalizerSettings.getGraphicEqPreamp() || 0;
const geqRange = equalizerSettings.getRange();
@@ -1288,7 +1295,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
};
- // Build 16 vertical slider bands into a container
+ // Build vertical slider bands into a container
const buildGeqBands = (container, idPrefix) => {
if (!container) return;
container.innerHTML = '';
@@ -1359,7 +1366,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
select.addEventListener('change', () => {
const key = select.value;
if (!key) return;
- const presets = getPresetsForBandCount(16);
+ const presets = getPresetsForBandCount(geqBandCount);
const preset = presets[key];
if (!preset) return;
geqGains = [...preset.gains];
@@ -1375,7 +1382,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Wire up reset buttons
[geqResetBtn, legacyGeqResetBtn].filter(Boolean).forEach((btn) => {
btn.addEventListener('click', () => {
- geqGains = new Array(16).fill(0);
+ geqGains = new Array(geqBandCount).fill(0);
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqAllGains(geqGains);
geqSyncAllSliders();
@@ -1383,6 +1390,49 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
});
+ // Band count and frequency range controls
+ const rebuildGeq = () => {
+ GEQ_FREQUENCIES = generateGeqFrequencies(geqBandCount, geqFreqRange.min, geqFreqRange.max);
+ GEQ_LABELS = GEQ_FREQUENCIES.map(formatGeqFreq);
+ buildGeqBands(geqBandsContainer, 'geq');
+ buildGeqBands(legacyGeqBandsContainer, 'legacy-geq');
+ geqSyncAllSliders();
+ };
+
+ if (geqBandCountInput) {
+ geqBandCountInput.value = geqBandCount;
+ geqBandCountInput.addEventListener('change', () => {
+ const newCount = Math.max(3, Math.min(32, parseInt(geqBandCountInput.value, 10) || 16));
+ geqBandCountInput.value = newCount;
+ if (newCount === geqBandCount) return;
+ geqGains = equalizerSettings._interpolateGains(geqGains, newCount);
+ geqBandCount = newCount;
+ audioContextManager.setGraphicEqBandCount(newCount);
+ rebuildGeq();
+ geqPresetSelects.forEach((s) => (s.value = ''));
+ });
+ }
+
+ if (geqFreqMinInput && geqFreqMaxInput) {
+ geqFreqMinInput.value = geqFreqRange.min;
+ geqFreqMaxInput.value = geqFreqRange.max;
+
+ const handleFreqRangeChange = () => {
+ const newMin = Math.max(10, Math.min(96000, parseInt(geqFreqMinInput.value, 10) || 25));
+ const newMax = Math.max(10, Math.min(96000, parseInt(geqFreqMaxInput.value, 10) || 20000));
+ geqFreqMinInput.value = newMin;
+ geqFreqMaxInput.value = newMax;
+ if (newMin >= newMax) return;
+ if (newMin === geqFreqRange.min && newMax === geqFreqRange.max) return;
+ geqFreqRange = { min: newMin, max: newMax };
+ audioContextManager.setGraphicEqFreqRange(newMin, newMax);
+ rebuildGeq();
+ };
+
+ geqFreqMinInput.addEventListener('change', handleFreqRangeChange);
+ geqFreqMaxInput.addEventListener('change', handleFreqRangeChange);
+ }
+
// Legacy EQ Import / Export
const parseGeqLabelFrequency = (label) => {
const normalized = String(label).trim().toLowerCase().replace(/\s+/g, '');
@@ -1395,7 +1445,6 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}
return Number.parseFloat(withoutHz);
};
- const GEQ_FREQUENCIES = GEQ_LABELS.map((label) => parseGeqLabelFrequency(label));
const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn');
const legacyGeqExportCsvBtn = document.getElementById('legacy-geq-export-csv-btn');
const legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn');
@@ -1405,7 +1454,8 @@ export async function initializeSettings(scrobbler, player, api, ui) {
legacyGeqExportBtn.addEventListener('click', () => {
const lines = [`Preamp: ${geqPreamp.toFixed(1)} dB`];
GEQ_FREQUENCIES.forEach((freq, i) => {
- lines.push(`Filter ${i + 1}: ON PK Fc ${freq} Hz Gain ${geqGains[i].toFixed(1)} dB Q 1.41`);
+ const q = (2.5 * Math.sqrt(16 / geqBandCount)).toFixed(2);
+ lines.push(`Filter ${i + 1}: ON PK Fc ${freq} Hz Gain ${geqGains[i].toFixed(1)} dB Q ${q}`);
});
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
@@ -1484,7 +1534,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Sort by frequency
validPoints.sort((a, b) => a.freq - b.freq);
- // Map imported points to the 16 GEQ bands using nearest-frequency matching
+ // Map imported points to the GEQ bands using nearest-frequency matching
const newGains = GEQ_FREQUENCIES.map((targetFreq) => {
// Find the closest imported point by log-frequency distance
let closest = validPoints[0];
@@ -1608,11 +1658,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const customPresets = getLegacyGeqCustomPresets();
if (customPresets[key]) {
const gains = customPresets[key]?.gains;
- if (!Array.isArray(gains) || gains.length !== GEQ_FREQUENCIES.length) {
+ if (!Array.isArray(gains) || gains.length === 0) {
updateDeleteBtnVisibility();
return;
}
- geqGains = gains.map((g) => {
+ const adjusted = gains.length !== geqBandCount
+ ? equalizerSettings._interpolateGains(gains, geqBandCount)
+ : gains;
+ geqGains = adjusted.map((g) => {
const n = Number(g);
return Number.isFinite(n)
? Math.max(parseFloat(geqRange.min), Math.min(parseFloat(geqRange.max), n))
@@ -3812,6 +3865,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const hp = document.getElementById('eq-howto-panel');
if (hp && hp.style.display !== 'none') {
const tabs = {
+ legacy: document.getElementById('eq-howto-legacy'),
autoeq: document.getElementById('eq-howto-autoeq'),
parametric: document.getElementById('eq-howto-parametric'),
speaker: document.getElementById('eq-howto-speaker'),
@@ -3834,6 +3888,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const howtoPanel = document.getElementById('eq-howto-panel');
const howtoClose = document.getElementById('eq-howto-close');
const howtoTabs = {
+ legacy: document.getElementById('eq-howto-legacy'),
autoeq: document.getElementById('eq-howto-autoeq'),
parametric: document.getElementById('eq-howto-parametric'),
speaker: document.getElementById('eq-howto-speaker'),
diff --git a/js/storage.js b/js/storage.js
index 0e58be6..ac4d5e4 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -1700,10 +1700,12 @@ export const equalizerSettings = {
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
},
- // --- Graphic EQ (16-band) separate storage ---
+ // --- Graphic EQ separate storage ---
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
GEQ_GAINS_KEY: 'graphic-eq-gains',
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
+ GEQ_BAND_COUNT_KEY: 'graphic-eq-band-count',
+ GEQ_FREQ_RANGE_KEY: 'graphic-eq-freq-range',
isGraphicEqEnabled() {
try {
@@ -1721,19 +1723,59 @@ export const equalizerSettings = {
}
},
- getGraphicEqGains() {
+ getGraphicEqBandCount() {
+ try {
+ const val = localStorage.getItem(this.GEQ_BAND_COUNT_KEY);
+ if (val !== null) {
+ const num = parseInt(val, 10);
+ if (num >= 3 && num <= 32) return num;
+ }
+ } catch { /* ignore */ }
+ return 16;
+ },
+
+ setGraphicEqBandCount(count) {
+ try {
+ localStorage.setItem(this.GEQ_BAND_COUNT_KEY, String(count));
+ } catch { /* ignore */ }
+ },
+
+ getGraphicEqFreqRange() {
+ try {
+ const stored = localStorage.getItem(this.GEQ_FREQ_RANGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed && Number.isFinite(parsed.min) && Number.isFinite(parsed.max)) {
+ return parsed;
+ }
+ }
+ } catch { /* ignore */ }
+ return { min: 25, max: 20000 };
+ },
+
+ setGraphicEqFreqRange(min, max) {
+ try {
+ localStorage.setItem(this.GEQ_FREQ_RANGE_KEY, JSON.stringify({ min, max }));
+ } catch { /* ignore */ }
+ },
+
+ getGraphicEqGains(bandCount) {
try {
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
- if (Array.isArray(parsed) && parsed.length === 16) {
+ const expectedCount = bandCount || this.getGraphicEqBandCount();
+ if (Array.isArray(parsed) && parsed.length === expectedCount) {
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
}
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ return this._interpolateGains(parsed, expectedCount);
+ }
}
} catch {
/* ignore */
}
- return new Array(16).fill(0);
+ return new Array(bandCount || this.getGraphicEqBandCount()).fill(0);
},
setGraphicEqGains(gains) {
diff --git a/styles.css b/styles.css
index b3d3241..d62e97c 100644
--- a/styles.css
+++ b/styles.css
@@ -7902,6 +7902,29 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
gap: var(--spacing-md);
}
+.graphic-eq-config-row {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ flex-wrap: wrap;
+}
+
+.graphic-eq-config-row label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--foreground);
+}
+
+.geq-config-input {
+ width: 70px;
+ padding: 4px 6px;
+ border: 1px solid var(--border-color, #444);
+ border-radius: 4px;
+ background: var(--bg-secondary, #222);
+ color: inherit;
+ font-size: 0.85rem;
+}
+
.graphic-eq-preset-row {
display: flex;
align-items: center;