diff --git a/index.html b/index.html
index 7e5f3d4..a6fc642 100644
--- a/index.html
+++ b/index.html
@@ -3396,23 +3396,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ to
+
+ dB
+
+
+
+
+
+
+
+ Hz to
+
+ Hz
+
+
+
diff --git a/js/audio-context.js b/js/audio-context.js
index ab6df0e..519abbd 100644
--- a/js/audio-context.js
+++ b/js/audio-context.js
@@ -1,79 +1,95 @@
// js/audio-context.js
// Shared Audio Context Manager - handles EQ and provides context for visualizer
+// Supports 3-32 parametric EQ bands
import { equalizerSettings, monoAudioSettings } from './storage.js';
-// Standard 16-band ISO center frequencies (Hz)
-const EQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
+// Standard 16-band ISO center frequencies (Hz) - for reference
+const DEFAULT_EQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
-// EQ Presets (gain values in dB for each of the 16 bands)
-const EQ_PRESETS = {
- flat: {
- name: 'Flat',
- gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- },
- bass_boost: {
- name: 'Bass Boost',
- gains: [6, 5, 4.5, 4, 3, 2, 1, 0.5, 0, 0, 0, 0, 0, 0, 0, 0],
- },
- bass_reducer: {
- name: 'Bass Reducer',
- gains: [-6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- },
- treble_boost: {
- name: 'Treble Boost',
- gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5.5, 6],
- },
- treble_reducer: {
- name: 'Treble Reducer',
- gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -5.5, -6],
- },
- vocal_boost: {
- name: 'Vocal Boost',
- gains: [-2, -1, 0, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 0, -1, -2],
- },
- loudness: {
- name: 'Loudness',
- gains: [5, 4, 3, 1, 0, -1, -1, 0, 0, 1, 2, 3, 4, 4.5, 4, 3],
- },
- rock: {
- name: 'Rock',
- gains: [4, 3.5, 3, 2, -1, -2, -1, 1, 2, 3, 3.5, 4, 4, 3, 2, 1],
- },
- pop: {
- name: 'Pop',
- gains: [-1, 0, 1, 2, 3, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1, 0],
- },
- classical: {
- name: 'Classical',
- gains: [3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 2],
- },
- jazz: {
- name: 'Jazz',
- gains: [3, 2, 1, 1, -1, -1, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2],
- },
- electronic: {
- name: 'Electronic',
- gains: [4, 3.5, 3, 1, 0, -1, 0, 1, 2, 3, 3, 2, 2, 3, 4, 3.5],
- },
- hip_hop: {
- name: 'Hip-Hop',
- gains: [5, 4.5, 4, 3, 1, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2],
- },
- r_and_b: {
- name: 'R&B',
- gains: [3, 5, 4, 2, 1, 0, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1],
- },
- acoustic: {
- name: 'Acoustic',
- gains: [3, 2, 1, 1, 2, 2, 1, 0, 0, 1, 1, 2, 3, 3, 2, 1],
- },
- podcast: {
- name: 'Podcast / Speech',
- gains: [-3, -2, -1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1, -2, -3],
- },
+// Generate frequency array for given number of bands using logarithmic spacing
+function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
+ const frequencies = [];
+ const safeMin = Math.max(10, minFreq);
+ const safeMax = Math.min(96000, maxFreq);
+
+ for (let i = 0; i < bandCount; i++) {
+ // Logarithmic interpolation
+ const t = i / (bandCount - 1);
+ const freq = safeMin * Math.pow(safeMax / safeMin, t);
+ frequencies.push(Math.round(freq));
+ }
+
+ return frequencies;
+}
+
+// Generate frequency labels for display
+function generateFrequencyLabels(frequencies) {
+ return frequencies.map((freq) => {
+ if (freq < 1000) {
+ return freq.toString();
+ } else if (freq < 10000) {
+ return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'K';
+ } else {
+ return (freq / 1000).toFixed(0) + 'K';
+ }
+ });
+}
+
+// EQ Presets (16-band default)
+const EQ_PRESETS_16 = {
+ flat: { name: 'Flat', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
+ bass_boost: { name: 'Bass Boost', gains: [6, 5, 4.5, 4, 3, 2, 1, 0.5, 0, 0, 0, 0, 0, 0, 0, 0] },
+ bass_reducer: { name: 'Bass Reducer', gains: [-6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
+ treble_boost: { name: 'Treble Boost', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5.5, 6] },
+ treble_reducer: { name: 'Treble Reducer', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -5.5, -6] },
+ vocal_boost: { name: 'Vocal Boost', gains: [-2, -1, 0, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 0, -1, -2] },
+ loudness: { name: 'Loudness', gains: [5, 4, 3, 1, 0, -1, -1, 0, 0, 1, 2, 3, 4, 4.5, 4, 3] },
+ rock: { name: 'Rock', gains: [4, 3.5, 3, 2, -1, -2, -1, 1, 2, 3, 3.5, 4, 4, 3, 2, 1] },
+ pop: { name: 'Pop', gains: [-1, 0, 1, 2, 3, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1, 0] },
+ classical: { name: 'Classical', gains: [3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 2] },
+ jazz: { name: 'Jazz', gains: [3, 2, 1, 1, -1, -1, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2] },
+ electronic: { name: 'Electronic', gains: [4, 3.5, 3, 1, 0, -1, 0, 1, 2, 3, 3, 2, 2, 3, 4, 3.5] },
+ hip_hop: { name: 'Hip-Hop', gains: [5, 4.5, 4, 3, 1, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2] },
+ r_and_b: { name: 'R&B', gains: [3, 5, 4, 2, 1, 0, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1] },
+ acoustic: { name: 'Acoustic', gains: [3, 2, 1, 1, 2, 2, 1, 0, 0, 1, 1, 2, 3, 3, 2, 1] },
+ podcast: { name: 'Podcast / Speech', gains: [-3, -2, -1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1, -2, -3] },
};
+// Interpolate 16-band preset to target band count
+function interpolatePreset(preset16, targetBands) {
+ if (targetBands === 16) return [...preset16];
+
+ const result = [];
+ for (let i = 0; i < targetBands; i++) {
+ const sourceIndex = (i / (targetBands - 1)) * (preset16.length - 1);
+ const indexLow = Math.floor(sourceIndex);
+ const indexHigh = Math.min(Math.ceil(sourceIndex), preset16.length - 1);
+ const fraction = sourceIndex - indexLow;
+
+ const lowValue = preset16[indexLow] || 0;
+ const highValue = preset16[indexHigh] || 0;
+ const interpolated = lowValue + (highValue - lowValue) * fraction;
+ result.push(Math.round(interpolated * 10) / 10);
+ }
+ return result;
+}
+
+// Get presets for given band count
+function getPresetsForBandCount(bandCount) {
+ const presets = {};
+ for (const [key, preset] of Object.entries(EQ_PRESETS_16)) {
+ presets[key] = {
+ name: preset.name,
+ gains: interpolatePreset(preset.gains, bandCount),
+ };
+ }
+ return presets;
+}
+
+// Default export for backwards compatibility (16 bands)
+const EQ_PRESETS = EQ_PRESETS_16;
+
class AudioContextManager {
constructor() {
this.audioContext = null;
@@ -86,10 +102,15 @@ class AudioContextManager {
this.isEQEnabled = false;
this.isMonoAudioEnabled = false;
this.monoMergerNode = null;
- this.currentGains = new Array(16).fill(0);
this.audio = null;
this.currentVolume = 1.0;
+ // Band configuration
+ this.bandCount = equalizerSettings.getBandCount();
+ this.freqRange = equalizerSettings.getFreqRange();
+ this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
+ this.currentGains = new Array(this.bandCount).fill(0);
+
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
@@ -97,6 +118,128 @@ class AudioContextManager {
this._loadSettings();
}
+ /**
+ * Update band count and reinitialize EQ
+ */
+ setBandCount(count) {
+ const newCount = Math.max(
+ equalizerSettings.MIN_BANDS,
+ Math.min(equalizerSettings.MAX_BANDS, parseInt(count, 10) || 16)
+ );
+
+ if (newCount === this.bandCount) return;
+
+ // Save new band count
+ equalizerSettings.setBandCount(newCount);
+
+ // Update configuration
+ this.bandCount = newCount;
+ this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
+
+ // Interpolate current gains to new band count
+ const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
+ this.currentGains = newGains;
+ equalizerSettings.setGains(newGains);
+
+ // Reinitialize EQ if already initialized
+ if (this.isInitialized && this.audioContext) {
+ this._destroyEQ();
+ this._createEQ();
+ }
+
+ // Dispatch event for UI update
+ window.dispatchEvent(
+ new CustomEvent('equalizer-band-count-changed', {
+ detail: { bandCount: newCount, frequencies: this.frequencies },
+ })
+ );
+ }
+
+ /**
+ * Update frequency range and reinitialize EQ
+ */
+ setFreqRange(minFreq, maxFreq) {
+ const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 20));
+ const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
+
+ if (newMin >= newMax) {
+ console.warn('[AudioContext] Invalid frequency range: min must be less than max');
+ return false;
+ }
+
+ if (newMin === this.freqRange.min && newMax === this.freqRange.max) return true;
+
+ // Save new frequency range
+ equalizerSettings.setFreqRange(newMin, newMax);
+
+ // Update configuration
+ this.freqRange = { min: newMin, max: newMax };
+ this.frequencies = generateFrequencies(this.bandCount, newMin, newMax);
+
+ // Reinitialize EQ if already initialized
+ if (this.isInitialized && this.audioContext) {
+ this._destroyEQ();
+ this._createEQ();
+ }
+
+ // Dispatch event for UI update
+ window.dispatchEvent(
+ new CustomEvent('equalizer-freq-range-changed', {
+ detail: { min: newMin, max: newMax, frequencies: this.frequencies },
+ })
+ );
+
+ return true;
+ }
+
+ /**
+ * Destroy EQ filters
+ */
+ _destroyEQ() {
+ if (this.filters) {
+ this.filters.forEach((filter) => {
+ try {
+ filter.disconnect();
+ } catch {
+ /* ignore */
+ }
+ });
+ }
+ this.filters = [];
+ }
+
+ /**
+ * Create EQ filters
+ */
+ _createEQ() {
+ if (!this.audioContext) return;
+
+ // Create biquad filters for each frequency band
+ this.filters = this.frequencies.map((freq, index) => {
+ const filter = this.audioContext.createBiquadFilter();
+ filter.type = 'peaking';
+ filter.frequency.value = freq;
+ filter.Q.value = this._calculateQ(index);
+ filter.gain.value = this.currentGains[index] || 0;
+ return filter;
+ });
+
+ // Create volume node if not exists
+ if (!this.volumeNode) {
+ this.volumeNode = this.audioContext.createGain();
+ }
+ }
+
+ /**
+ * Calculate Q factor for each band
+ */
+ _calculateQ(_index) {
+ // Scale Q based on band count for consistent sound
+ const baseQ = 2.5;
+ const scalingFactor = Math.sqrt(16 / this.bandCount);
+ return baseQ * scalingFactor;
+ }
+
/**
* Register a callback to be called when audio graph is reconnected
* @param {Function} callback - Function to call when graph changes
@@ -159,15 +302,8 @@ class AudioContextManager {
this.analyser.fftSize = 512;
this.analyser.smoothingTimeConstant = 0.7;
- // Create 16 biquad filters for EQ
- this.filters = EQ_FREQUENCIES.map((freq, index) => {
- const filter = this.audioContext.createBiquadFilter();
- filter.type = 'peaking';
- filter.frequency.value = freq;
- filter.Q.value = 2.5; // Constant-Q design
- filter.gain.value = this.currentGains[index];
- return filter;
- });
+ // Create biquad filters for EQ with dynamic band count
+ this._createEQ();
// Create output gain node
this.outputNode = this.audioContext.createGain();
@@ -180,17 +316,11 @@ class AudioContextManager {
// Create mono audio merger node
this.monoMergerNode = this.audioContext.createChannelMerger(2);
- // Connect filter chain: filter[0] -> filter[1] -> ... -> filter[15] -> outputNode
- for (let i = 0; i < this.filters.length - 1; i++) {
- this.filters[i].connect(this.filters[i + 1]);
- }
- this.filters[this.filters.length - 1].connect(this.outputNode);
-
// Connect the audio graph based on EQ and mono state
this._connectGraph();
this.isInitialized = true;
- console.log('[AudioContext] Initialized with 16-band EQ');
+ console.log(`[AudioContext] Initialized with ${this.bandCount}-band EQ`);
} catch (e) {
console.warn('[AudioContext] Init failed:', e);
}
@@ -240,7 +370,13 @@ class AudioContextManager {
if (this.isEQEnabled && this.filters.length > 0) {
// EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination
+ // Connect filter chain
+ for (let i = 0; i < this.filters.length - 1; i++) {
+ this.filters[i].connect(this.filters[i + 1]);
+ }
+ // Connect input to first filter and last filter to output
lastNode.connect(this.filters[0]);
+ this.filters[this.filters.length - 1].connect(this.outputNode);
this.outputNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
@@ -374,13 +510,28 @@ class AudioContextManager {
return this.isInitialized && this.isMonoAudioEnabled;
}
+ /**
+ * Get current gain range
+ */
+ getRange() {
+ return equalizerSettings.getRange();
+ }
+
+ /**
+ * Clamp gain to valid range
+ */
+ _clampGain(gainDb) {
+ const range = this.getRange();
+ return Math.max(range.min, Math.min(range.max, gainDb));
+ }
+
/**
* Set gain for a specific band
*/
setBandGain(bandIndex, gainDb) {
- if (bandIndex < 0 || bandIndex >= 16) return;
+ if (bandIndex < 0 || bandIndex >= this.bandCount) return;
- const clampedGain = Math.max(-30, Math.min(30, gainDb));
+ const clampedGain = this._clampGain(gainDb);
this.currentGains[bandIndex] = clampedGain;
if (this.filters[bandIndex] && this.audioContext) {
@@ -395,12 +546,18 @@ class AudioContextManager {
* Set all band gains at once
*/
setAllGains(gains) {
- if (!Array.isArray(gains) || gains.length !== 16) return;
+ if (!Array.isArray(gains)) return;
+
+ // Ensure gains array matches current band count
+ let adjustedGains = gains;
+ if (gains.length !== this.bandCount) {
+ adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
+ }
const now = this.audioContext?.currentTime || 0;
- gains.forEach((gain, index) => {
- const clampedGain = Math.max(-30, Math.min(30, gain));
+ adjustedGains.forEach((gain, index) => {
+ const clampedGain = this._clampGain(gain);
this.currentGains[index] = clampedGain;
if (this.filters[index]) {
@@ -415,7 +572,8 @@ class AudioContextManager {
* Apply a preset
*/
applyPreset(presetKey) {
- const preset = EQ_PRESETS[presetKey];
+ const presets = getPresetsForBandCount(this.bandCount);
+ const preset = presets[presetKey];
if (!preset) return;
this.setAllGains(preset.gains);
@@ -426,7 +584,7 @@ class AudioContextManager {
* Reset all bands to flat
*/
reset() {
- this.setAllGains(new Array(16).fill(0));
+ this.setAllGains(new Array(this.bandCount).fill(0));
equalizerSettings.setPreset('flat');
}
@@ -437,12 +595,22 @@ class AudioContextManager {
return [...this.currentGains];
}
+ /**
+ * Get current band count
+ */
+ getBandCount() {
+ return this.bandCount;
+ }
+
/**
* Load settings from storage
*/
_loadSettings() {
this.isEQEnabled = equalizerSettings.isEnabled();
- this.currentGains = equalizerSettings.getGains();
+ this.bandCount = equalizerSettings.getBandCount();
+ this.freqRange = equalizerSettings.getFreqRange();
+ this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
+ this.currentGains = equalizerSettings.getGains(this.bandCount);
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
}
}
@@ -450,5 +618,12 @@ class AudioContextManager {
// Export singleton instance
export const audioContextManager = new AudioContextManager();
-// Export presets for settings UI
-export { EQ_PRESETS };
+// Export presets and helper functions for settings UI
+export {
+ EQ_PRESETS,
+ generateFrequencies,
+ generateFrequencyLabels,
+ getPresetsForBandCount,
+ interpolatePreset,
+ EQ_PRESETS_16,
+};
diff --git a/js/equalizer.js b/js/equalizer.js
index e372c42..6c87bb6 100644
--- a/js/equalizer.js
+++ b/js/equalizer.js
@@ -1,13 +1,13 @@
// js/equalizer.js
-// 16-Band Parametric Equalizer with Web Audio API
+// Parametric Equalizer with Web Audio API - Supports 3-32 bands
import { equalizerSettings } from './storage.js';
-// Standard 16-band ISO center frequencies (Hz)
-const EQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
+// Standard 16-band ISO center frequencies (Hz) - kept for reference
+const DEFAULT_EQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
// Frequency labels for UI display
-const FREQUENCY_LABELS = [
+const DEFAULT_FREQUENCY_LABELS = [
'25',
'40',
'63',
@@ -26,8 +26,37 @@ const FREQUENCY_LABELS = [
'20K',
];
+// Generate frequency array for given number of bands using logarithmic spacing
+function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
+ const frequencies = [];
+ const safeMin = Math.max(10, minFreq);
+ const safeMax = Math.min(96000, maxFreq);
+
+ for (let i = 0; i < bandCount; i++) {
+ // Logarithmic interpolation
+ const t = i / (bandCount - 1);
+ const freq = safeMin * Math.pow(safeMax / safeMin, t);
+ frequencies.push(Math.round(freq));
+ }
+
+ return frequencies;
+}
+
+// Generate frequency labels for display
+function generateFrequencyLabels(frequencies) {
+ return frequencies.map((freq) => {
+ if (freq < 1000) {
+ return freq.toString();
+ } else if (freq < 10000) {
+ return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'K';
+ } else {
+ return (freq / 1000).toFixed(0) + 'K';
+ }
+ });
+}
+
// EQ Presets (gain values in dB for each of the 16 bands)
-const EQ_PRESETS = {
+const EQ_PRESETS_16BAND = {
flat: {
name: 'Flat',
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
@@ -94,6 +123,37 @@ const EQ_PRESETS = {
},
};
+// Interpolate 16-band preset to target band count
+function interpolatePreset(preset16, targetBands) {
+ if (targetBands === 16) return [...preset16];
+
+ const result = [];
+ for (let i = 0; i < targetBands; i++) {
+ const sourceIndex = (i / (targetBands - 1)) * (preset16.length - 1);
+ const indexLow = Math.floor(sourceIndex);
+ const indexHigh = Math.min(Math.ceil(sourceIndex), preset16.length - 1);
+ const fraction = sourceIndex - indexLow;
+
+ const lowValue = preset16[indexLow] || 0;
+ const highValue = preset16[indexHigh] || 0;
+ const interpolated = lowValue + (highValue - lowValue) * fraction;
+ result.push(Math.round(interpolated * 10) / 10);
+ }
+ return result;
+}
+
+// Get presets for given band count
+function getPresetsForBandCount(bandCount) {
+ const presets = {};
+ for (const [key, preset] of Object.entries(EQ_PRESETS_16BAND)) {
+ presets[key] = {
+ name: preset.name,
+ gains: interpolatePreset(preset.gains, bandCount),
+ };
+ }
+ return presets;
+}
+
export class Equalizer {
constructor() {
this.audioContext = null;
@@ -105,13 +165,99 @@ export class Equalizer {
this.isInitialized = false;
this.audio = null;
+ // Band configuration
+ this.bandCount = equalizerSettings.getBandCount();
+ this.freqRange = equalizerSettings.getFreqRange();
+ this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
+ this.frequencyLabels = generateFrequencyLabels(this.frequencies);
+
// Store current gains
- this.currentGains = new Array(16).fill(0);
+ this.currentGains = new Array(this.bandCount).fill(0);
// Load saved settings
this._loadSettings();
}
+ /**
+ * Update band count and reinitialize
+ */
+ setBandCount(count) {
+ const newCount = Math.max(
+ equalizerSettings.MIN_BANDS,
+ Math.min(equalizerSettings.MAX_BANDS, parseInt(count, 10) || 16)
+ );
+
+ if (newCount === this.bandCount) return;
+
+ // Save new band count
+ equalizerSettings.setBandCount(newCount);
+
+ // Update configuration
+ this.bandCount = newCount;
+ this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
+ this.frequencyLabels = generateFrequencyLabels(this.frequencies);
+
+ // Interpolate current gains to new band count
+ const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
+ this.currentGains = newGains;
+ equalizerSettings.setGains(newGains);
+
+ // Reinitialize if already initialized
+ if (this.isInitialized) {
+ this.destroy();
+ if (this.audioContext && this.source && this.audio) {
+ this.init(this.audioContext, this.source, this.audio);
+ }
+ }
+
+ // Dispatch event for UI update
+ window.dispatchEvent(
+ new CustomEvent('equalizer-band-count-changed', {
+ detail: { bandCount: newCount, frequencies: this.frequencies, labels: this.frequencyLabels },
+ })
+ );
+ }
+
+ /**
+ * Update frequency range and reinitialize
+ */
+ setFreqRange(minFreq, maxFreq) {
+ const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 20));
+ const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
+
+ if (newMin >= newMax) {
+ console.warn('[Equalizer] Invalid frequency range: min must be less than max');
+ return false;
+ }
+
+ if (newMin === this.freqRange.min && newMax === this.freqRange.max) return true;
+
+ // Save new frequency range
+ equalizerSettings.setFreqRange(newMin, newMax);
+
+ // Update configuration
+ this.freqRange = { min: newMin, max: newMax };
+ this.frequencies = generateFrequencies(this.bandCount, newMin, newMax);
+ this.frequencyLabels = generateFrequencyLabels(this.frequencies);
+
+ // Reinitialize if already initialized
+ if (this.isInitialized) {
+ this.destroy();
+ if (this.audioContext && this.source && this.audio) {
+ this.init(this.audioContext, this.source, this.audio);
+ }
+ }
+
+ // Dispatch event for UI update
+ window.dispatchEvent(
+ new CustomEvent('equalizer-freq-range-changed', {
+ detail: { min: newMin, max: newMax, frequencies: this.frequencies, labels: this.frequencyLabels },
+ })
+ );
+
+ return true;
+ }
+
/**
* Initialize the equalizer with a shared AudioContext
* This should be called after the visualizer creates the context
@@ -127,15 +273,15 @@ export class Equalizer {
this.source = sourceNode;
this.audio = audioElement;
- // Create 16 biquad filters for each frequency band
- this.filters = EQ_FREQUENCIES.map((freq, index) => {
+ // Create biquad filters for each frequency band
+ this.filters = this.frequencies.map((freq, index) => {
const filter = this.audioContext.createBiquadFilter();
// Use peaking filter for all bands (best for EQ)
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = this._calculateQ(index);
- filter.gain.value = this.currentGains[index];
+ filter.gain.value = this.currentGains[index] || 0;
return filter;
});
@@ -154,7 +300,7 @@ export class Equalizer {
this._enableFilters();
}
- console.log('[Equalizer] Initialized with 16 bands');
+ console.log(`[Equalizer] Initialized with ${this.bandCount} bands`);
} catch (e) {
console.warn('[Equalizer] Init failed:', e);
}
@@ -167,7 +313,10 @@ export class Equalizer {
_calculateQ(_index) {
// For 16-band 1/2 octave spacing, Q ≈ 2.87
// Slightly lower Q for smoother response
- return 2.5;
+ // Scale Q based on band count for consistent sound
+ const baseQ = 2.5;
+ const scalingFactor = Math.sqrt(16 / this.bandCount);
+ return baseQ * scalingFactor;
}
/**
@@ -247,16 +396,31 @@ export class Equalizer {
return this.isEnabled;
}
+ /**
+ * Get current gain range from settings
+ */
+ getRange() {
+ return equalizerSettings.getRange();
+ }
+
+ /**
+ * Clamp gain to current range
+ */
+ _clampGain(gainDb) {
+ const range = this.getRange();
+ return Math.max(range.min, Math.min(range.max, gainDb));
+ }
+
/**
* Set gain for a specific band
- * @param {number} bandIndex - Band index (0-15)
- * @param {number} gainDb - Gain in dB (-12 to +12)
+ * @param {number} bandIndex - Band index
+ * @param {number} gainDb - Gain in dB
*/
setBandGain(bandIndex, gainDb) {
- if (bandIndex < 0 || bandIndex >= 16) return;
+ if (bandIndex < 0 || bandIndex >= this.bandCount) return;
// Clamp gain to valid range
- const clampedGain = Math.max(-30, Math.min(30, gainDb));
+ const clampedGain = this._clampGain(gainDb);
this.currentGains[bandIndex] = clampedGain;
if (this.filters[bandIndex]) {
@@ -271,15 +435,21 @@ export class Equalizer {
/**
* Set all band gains at once
- * @param {number[]} gains - Array of 16 gain values in dB
+ * @param {number[]} gains - Array of gain values in dB
*/
setAllGains(gains) {
- if (!Array.isArray(gains) || gains.length !== 16) return;
+ if (!Array.isArray(gains)) return;
+
+ // Ensure gains array matches current band count
+ let adjustedGains = gains;
+ if (gains.length !== this.bandCount) {
+ adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
+ }
const now = this.audioContext?.currentTime || 0;
- gains.forEach((gain, index) => {
- const clampedGain = Math.max(-30, Math.min(30, gain));
+ adjustedGains.forEach((gain, index) => {
+ const clampedGain = this._clampGain(gain);
this.currentGains[index] = clampedGain;
if (this.filters[index]) {
@@ -295,7 +465,8 @@ export class Equalizer {
* @param {string} presetKey - Key from EQ_PRESETS
*/
applyPreset(presetKey) {
- const preset = EQ_PRESETS[presetKey];
+ const presets = getPresetsForBandCount(this.bandCount);
+ const preset = presets[presetKey];
if (!preset) return;
this.setAllGains(preset.gains);
@@ -306,37 +477,47 @@ export class Equalizer {
* Reset all bands to flat (0 dB)
*/
reset() {
- this.setAllGains(new Array(16).fill(0));
+ this.setAllGains(new Array(this.bandCount).fill(0));
equalizerSettings.setPreset('flat');
}
/**
* Get current gains
- * @returns {number[]} Array of 16 gain values
+ * @returns {number[]} Array of gain values
*/
getGains() {
return [...this.currentGains];
}
/**
- * Get frequency labels
+ * Get current band count
+ * @returns {number} Number of bands
*/
- static getFrequencyLabels() {
- return FREQUENCY_LABELS;
+ getBandCount() {
+ return this.bandCount;
+ }
+
+ /**
+ * Get frequency labels for UI
+ * @returns {string[]} Array of frequency labels
+ */
+ getFrequencyLabels() {
+ return this.frequencyLabels;
}
/**
* Get frequencies
+ * @returns {number[]} Array of frequency values
*/
- static getFrequencies() {
- return EQ_FREQUENCIES;
+ getFrequencies() {
+ return this.frequencies;
}
/**
- * Get available presets
+ * Get available presets (static method for default 16 bands)
*/
- static getPresets() {
- return EQ_PRESETS;
+ static getPresets(bandCount = 16) {
+ return getPresetsForBandCount(bandCount);
}
/**
@@ -344,7 +525,11 @@ export class Equalizer {
*/
_loadSettings() {
this.isEnabled = equalizerSettings.isEnabled();
- this.currentGains = equalizerSettings.getGains();
+ this.bandCount = equalizerSettings.getBandCount();
+ this.freqRange = equalizerSettings.getFreqRange();
+ this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
+ this.frequencyLabels = generateFrequencyLabels(this.frequencies);
+ this.currentGains = equalizerSettings.getGains(this.bandCount);
}
/**
@@ -380,5 +565,13 @@ export class Equalizer {
// Export singleton instance
export const equalizer = new Equalizer();
-// Export constants
-export { EQ_FREQUENCIES, FREQUENCY_LABELS, EQ_PRESETS };
+// Export helper functions and constants
+export {
+ generateFrequencies,
+ generateFrequencyLabels,
+ getPresetsForBandCount,
+ interpolatePreset,
+ DEFAULT_EQ_FREQUENCIES,
+ DEFAULT_FREQUENCY_LABELS,
+ EQ_PRESETS_16BAND as EQ_PRESETS,
+};
diff --git a/js/settings.js b/js/settings.js
index 86783e1..6fa82ab 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -858,13 +858,111 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
// ========================================
- // 16-Band Equalizer Settings
+ // Parametric Equalizer Settings (3-32 bands with custom ranges)
// ========================================
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 eqBands = document.querySelectorAll('.eq-band');
+ 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');
+
+ // Current settings
+ let currentBandCount = equalizerSettings.getBandCount();
+ let currentRange = equalizerSettings.getRange();
+ let currentFreqRange = equalizerSettings.getFreqRange();
+
+ /**
+ * 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);
+
+ 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);
+
+ 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');
+ }
+ }
+
+ 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`;
+ }
+ };
/**
* Update the visual display of a band value
@@ -889,6 +987,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
* 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) {
@@ -907,48 +1008,59 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
};
- // Initialize EQ toggle
- if (eqToggle) {
- const isEnabled = equalizerSettings.isEnabled();
- eqToggle.checked = isEnabled;
- updateEQContainerVisibility(isEnabled);
+ /**
+ * Populate custom presets in the dropdown
+ */
+ const populateCustomPresets = () => {
+ if (!customPresetsOptgroup) return;
- eqToggle.addEventListener('change', (e) => {
- const enabled = e.target.checked;
- audioContextManager.toggleEQ(enabled);
- updateEQContainerVisibility(enabled);
- });
- }
+ // Clear existing custom presets
+ customPresetsOptgroup.innerHTML = '';
- // Initialize preset selector
- if (eqPresetSelect) {
- eqPresetSelect.value = equalizerSettings.getPreset();
+ const customPresets = equalizerSettings.getCustomPresets();
+ const presetIds = Object.keys(customPresets);
- eqPresetSelect.addEventListener('change', (e) => {
- const presetKey = e.target.value;
- const preset = EQ_PRESETS[presetKey];
+ 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);
+ });
+ }
+ };
- if (preset) {
- audioContextManager.applyPreset(presetKey);
- updateAllBandUI(preset.gains);
- }
- });
- }
+ /**
+ * Check if a preset ID is a custom preset
+ */
+ const isCustomPreset = (presetId) => {
+ return presetId && presetId.startsWith('custom_');
+ };
- // Initialize reset button
- if (eqResetBtn) {
- eqResetBtn.addEventListener('click', () => {
- audioContextManager.reset();
- updateAllBandUI(new Array(16).fill(0));
- if (eqPresetSelect) {
- eqPresetSelect.value = 'flat';
- }
- });
- }
+ /**
+ * Update delete button visibility based on selected preset
+ */
+ const updateDeleteButtonVisibility = () => {
+ if (!deleteCustomPresetBtn || !eqPresetSelect) return;
+ const isCustom = isCustomPreset(eqPresetSelect.value);
+ deleteCustomPresetBtn.style.display = isCustom ? 'flex' : 'none';
+ };
- // Initialize all band sliders
- if (eqBands.length > 0) {
- const savedGains = equalizerSettings.getGains();
+ /**
+ * Initialize band slider event listeners
+ */
+ const initializeBandSliders = () => {
+ const eqBands = eqBandsContainer?.querySelectorAll('.eq-band');
+ if (!eqBands || eqBands.length === 0) return;
+
+ const savedGains = equalizerSettings.getGains(currentBandCount);
eqBands.forEach((bandEl) => {
const bandIndex = parseInt(bandEl.dataset.band, 10);
@@ -966,16 +1078,15 @@ export function initializeSettings(scrobbler, player, api, ui) {
audioContextManager.setBandGain(bandIndex, gain);
updateBandValueDisplay(bandEl, gain);
- // When manually adjusting, switch preset to 'flat' (custom)
- // to indicate the user has made custom changes
+ // When manually adjusting, check if we should clear preset
if (eqPresetSelect && eqPresetSelect.value !== 'flat') {
- // Check if current gains still match the selected preset
- const currentPreset = EQ_PRESETS[eqPresetSelect.value];
+ const currentGains = audioContextManager.getGains();
+ const builtInPresets = EQ_PRESETS;
+ const currentPreset = builtInPresets[eqPresetSelect.value];
if (currentPreset) {
- const currentGains = audioContextManager.getGains();
const matches = currentPreset.gains.every((g, i) => Math.abs(g - currentGains[i]) < 0.01);
if (!matches) {
- // Don't change the select, but the preset will save as 'custom'
+ // User has deviated from preset
}
}
}
@@ -989,8 +1100,423 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
});
+ };
+
+ // Initialize EQ toggle
+ if (eqToggle) {
+ const isEnabled = equalizerSettings.isEnabled();
+ eqToggle.checked = isEnabled;
+ updateEQContainerVisibility(isEnabled);
+
+ eqToggle.addEventListener('change', (e) => {
+ const enabled = e.target.checked;
+ audioContextManager.toggleEQ(enabled);
+ updateEQContainerVisibility(enabled);
+ });
}
+ // 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
+ equalizerSettings.setBandCount(newCount);
+ audioContextManager.setBandCount?.(newCount) || audioContextManager.reinitialize?.();
+
+ // Regenerate UI
+ generateEQBands(
+ newCount,
+ currentRange.min,
+ currentRange.max,
+ currentFreqRange.min,
+ currentFreqRange.max
+ );
+
+ // Reset to flat and apply
+ const flatGains = new Array(newCount).fill(0);
+ audioContextManager.setAllGains(flatGains);
+ updateAllBandUI(flatGains);
+
+ if (eqPresetSelect) {
+ eqPresetSelect.value = 'flat';
+ equalizerSettings.setPreset('flat');
+ }
+ updateDeleteButtonVisibility();
+ }
+ });
+ }
+
+ // 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);
+ });
+ }
+
+ // Generate initial EQ bands with current ranges
+ generateEQBands(currentBandCount, currentRange.min, currentRange.max, currentFreqRange.min, currentFreqRange.max);
+
+ // 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
+ );
+ }
+ });
+
+ // 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
+ );
+ }
+ });
+
// Now Playing Mode
const nowPlayingMode = document.getElementById('now-playing-mode');
if (nowPlayingMode) {
diff --git a/js/storage.js b/js/storage.js
index 1953feb..8716f0d 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -791,6 +791,23 @@ export const equalizerSettings = {
ENABLED_KEY: 'equalizer-enabled',
GAINS_KEY: 'equalizer-gains',
PRESET_KEY: 'equalizer-preset',
+ CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
+ BAND_COUNT_KEY: 'equalizer-band-count',
+ RANGE_MIN_KEY: 'equalizer-range-min',
+ RANGE_MAX_KEY: 'equalizer-range-max',
+ FREQ_MIN_KEY: 'equalizer-freq-min',
+ FREQ_MAX_KEY: 'equalizer-freq-max',
+ DEFAULT_BAND_COUNT: 16,
+ MIN_BANDS: 3,
+ MAX_BANDS: 32,
+ DEFAULT_RANGE_MIN: -30,
+ DEFAULT_RANGE_MAX: 30,
+ ABSOLUTE_MIN: -60,
+ ABSOLUTE_MAX: 60,
+ DEFAULT_FREQ_MIN: 20,
+ DEFAULT_FREQ_MAX: 20000,
+ ABSOLUTE_FREQ_MIN: 10,
+ ABSOLUTE_FREQ_MAX: 96000,
isEnabled() {
try {
@@ -805,25 +822,178 @@ export const equalizerSettings = {
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
},
- getGains() {
+ getBandCount() {
+ try {
+ const stored = localStorage.getItem(this.BAND_COUNT_KEY);
+ if (stored) {
+ const count = parseInt(stored, 10);
+ if (!isNaN(count) && count >= this.MIN_BANDS && count <= this.MAX_BANDS) {
+ return count;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return this.DEFAULT_BAND_COUNT;
+ },
+
+ setBandCount(count) {
+ const validCount = Math.max(
+ this.MIN_BANDS,
+ Math.min(this.MAX_BANDS, parseInt(count, 10) || this.DEFAULT_BAND_COUNT)
+ );
+ localStorage.setItem(this.BAND_COUNT_KEY, validCount.toString());
+ },
+
+ getRangeMin() {
+ try {
+ const stored = localStorage.getItem(this.RANGE_MIN_KEY);
+ if (stored) {
+ const val = parseInt(stored, 10);
+ if (!isNaN(val) && val >= this.ABSOLUTE_MIN && val < 0) {
+ return val;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return this.DEFAULT_RANGE_MIN;
+ },
+
+ setRangeMin(value) {
+ const val = parseInt(value, 10);
+ if (!isNaN(val) && val >= this.ABSOLUTE_MIN && val < 0) {
+ localStorage.setItem(this.RANGE_MIN_KEY, val.toString());
+ return true;
+ }
+ return false;
+ },
+
+ getRangeMax() {
+ try {
+ const stored = localStorage.getItem(this.RANGE_MAX_KEY);
+ if (stored) {
+ const val = parseInt(stored, 10);
+ if (!isNaN(val) && val > 0 && val <= this.ABSOLUTE_MAX) {
+ return val;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return this.DEFAULT_RANGE_MAX;
+ },
+
+ setRangeMax(value) {
+ const val = parseInt(value, 10);
+ if (!isNaN(val) && val > 0 && val <= this.ABSOLUTE_MAX) {
+ localStorage.setItem(this.RANGE_MAX_KEY, val.toString());
+ return true;
+ }
+ return false;
+ },
+
+ getRange() {
+ return {
+ min: this.getRangeMin(),
+ max: this.getRangeMax(),
+ };
+ },
+
+ setRange(min, max) {
+ const validMin = this.setRangeMin(min);
+ const validMax = this.setRangeMax(max);
+ return validMin && validMax;
+ },
+
+ getFreqMin() {
+ try {
+ const stored = localStorage.getItem(this.FREQ_MIN_KEY);
+ if (stored) {
+ const val = parseInt(stored, 10);
+ if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < this.DEFAULT_FREQ_MAX) {
+ return val;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return this.DEFAULT_FREQ_MIN;
+ },
+
+ setFreqMin(value) {
+ const val = parseInt(value, 10);
+ if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < this.getFreqMax()) {
+ localStorage.setItem(this.FREQ_MIN_KEY, val.toString());
+ return true;
+ }
+ return false;
+ },
+
+ getFreqMax() {
+ try {
+ const stored = localStorage.getItem(this.FREQ_MAX_KEY);
+ if (stored) {
+ const val = parseInt(stored, 10);
+ if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) {
+ return val;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return this.DEFAULT_FREQ_MAX;
+ },
+
+ setFreqMax(value) {
+ const val = parseInt(value, 10);
+ if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) {
+ localStorage.setItem(this.FREQ_MAX_KEY, val.toString());
+ return true;
+ }
+ return false;
+ },
+
+ getFreqRange() {
+ return {
+ min: this.getFreqMin(),
+ max: this.getFreqMax(),
+ };
+ },
+
+ setFreqRange(min, max) {
+ const validMax = this.setFreqMax(max);
+ const validMin = this.setFreqMin(min);
+ return validMin && validMax;
+ },
+
+ getGains(bandCount) {
+ const count = bandCount || this.getBandCount();
try {
const stored = localStorage.getItem(this.GAINS_KEY);
if (stored) {
const gains = JSON.parse(stored);
- if (Array.isArray(gains) && gains.length === 16) {
- return gains;
+ if (Array.isArray(gains)) {
+ // If stored gains match current band count, return them
+ if (gains.length === count) {
+ return gains;
+ }
+ // If different band count, try to interpolate or return flat
+ if (gains.length > 0) {
+ return this._interpolateGains(gains, count);
+ }
}
}
} catch {
/* ignore */
}
// Return flat EQ (all zeros) by default
- return new Array(16).fill(0);
+ return new Array(count).fill(0);
},
setGains(gains) {
try {
- if (Array.isArray(gains) && gains.length === 16) {
+ if (Array.isArray(gains) && gains.length >= this.MIN_BANDS && gains.length <= this.MAX_BANDS) {
localStorage.setItem(this.GAINS_KEY, JSON.stringify(gains));
}
} catch (e) {
@@ -831,6 +1001,31 @@ export const equalizerSettings = {
}
},
+ /**
+ * Interpolate gains array to match target band count
+ */
+ _interpolateGains(sourceGains, targetCount) {
+ if (sourceGains.length === targetCount) {
+ return [...sourceGains];
+ }
+
+ const result = [];
+ for (let i = 0; i < targetCount; i++) {
+ // Map target index to source index
+ const sourceIndex = (i / (targetCount - 1)) * (sourceGains.length - 1);
+ const indexLow = Math.floor(sourceIndex);
+ const indexHigh = Math.min(Math.ceil(sourceIndex), sourceGains.length - 1);
+ const fraction = sourceIndex - indexLow;
+
+ // Linear interpolation
+ const lowValue = sourceGains[indexLow] || 0;
+ const highValue = sourceGains[indexHigh] || 0;
+ const interpolated = lowValue + (highValue - lowValue) * fraction;
+ result.push(Math.round(interpolated * 10) / 10);
+ }
+ return result;
+ },
+
getPreset() {
try {
return localStorage.getItem(this.PRESET_KEY) || 'flat';
@@ -842,6 +1037,102 @@ export const equalizerSettings = {
setPreset(preset) {
localStorage.setItem(this.PRESET_KEY, preset);
},
+
+ // Custom Preset Methods
+ getCustomPresets() {
+ try {
+ const stored = localStorage.getItem(this.CUSTOM_PRESETS_KEY);
+ if (stored) {
+ const presets = JSON.parse(stored);
+ if (typeof presets === 'object' && presets !== null) {
+ return presets;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return {};
+ },
+
+ saveCustomPreset(name, gains) {
+ try {
+ if (!name || !Array.isArray(gains) || gains.length < this.MIN_BANDS || gains.length > this.MAX_BANDS) {
+ console.warn('[EQ] Invalid preset data');
+ return false;
+ }
+
+ // Sanitize name - remove special characters and limit length
+ const sanitizedName = name
+ .trim()
+ .substring(0, 50)
+ .replace(/[^\w\s-]/g, '');
+ if (!sanitizedName) {
+ console.warn('[EQ] Invalid preset name');
+ return false;
+ }
+
+ const presets = this.getCustomPresets();
+ const presetId = 'custom_' + Date.now();
+
+ presets[presetId] = {
+ name: sanitizedName,
+ gains: gains.map((g) => Math.round(g * 10) / 10), // Round to 1 decimal place
+ bandCount: gains.length,
+ createdAt: Date.now(),
+ };
+
+ localStorage.setItem(this.CUSTOM_PRESETS_KEY, JSON.stringify(presets));
+ return presetId;
+ } catch (e) {
+ console.warn('[EQ] Failed to save custom preset:', e);
+ return false;
+ }
+ },
+
+ deleteCustomPreset(presetId) {
+ try {
+ const presets = this.getCustomPresets();
+ if (presets[presetId]) {
+ delete presets[presetId];
+ localStorage.setItem(this.CUSTOM_PRESETS_KEY, JSON.stringify(presets));
+ return true;
+ }
+ return false;
+ } catch (e) {
+ console.warn('[EQ] Failed to delete custom preset:', e);
+ return false;
+ }
+ },
+
+ updateCustomPreset(presetId, name, gains) {
+ try {
+ const presets = this.getCustomPresets();
+ if (!presets[presetId]) {
+ return false;
+ }
+
+ if (name !== undefined) {
+ const sanitizedName = name
+ .trim()
+ .substring(0, 50)
+ .replace(/[^\w\s-]/g, '');
+ if (sanitizedName) {
+ presets[presetId].name = sanitizedName;
+ }
+ }
+
+ if (Array.isArray(gains) && gains.length === 16) {
+ presets[presetId].gains = gains.map((g) => Math.round(g * 10) / 10);
+ presets[presetId].updatedAt = Date.now();
+ }
+
+ localStorage.setItem(this.CUSTOM_PRESETS_KEY, JSON.stringify(presets));
+ return true;
+ } catch (e) {
+ console.warn('[EQ] Failed to update custom preset:', e);
+ return false;
+ }
+ },
};
export const monoAudioSettings = {
diff --git a/styles.css b/styles.css
index 131544e..0d8bb4f 100644
--- a/styles.css
+++ b/styles.css
@@ -5805,6 +5805,42 @@ textarea:focus {
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 {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.eq-band-count-input[type='number'] {
+ -moz-appearance: textfield;
+}
+
#equalizer-reset-btn {
padding: 0.5rem;
display: flex;
@@ -5831,6 +5867,214 @@ textarea:focus {
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);
+}
+
+#custom-preset-name {
+ 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;
+ transition: border-color var(--transition-fast);
+}
+
+#custom-preset-name:focus {
+ outline: none;
+ border-color: var(--ring);
+ box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2);
+}
+
+#save-custom-preset-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: 0.5rem 1rem;
+ white-space: nowrap;
+}
+
+#save-custom-preset-btn svg {
+ flex-shrink: 0;
+}
+
+.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);
+}
+
+.delete-preset-btn:hover {
+ opacity: 1;
+ background: var(--destructive);
+ color: var(--destructive-foreground);
+}
+
+.delete-preset-btn svg {
+ flex-shrink: 0;
+}
+
+/* EQ Range Controls */
+.eq-range-controls {
+ 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;
+ color: var(--muted-foreground);
+ font-weight: 500;
+}
+
+.eq-range-controls span {
+ font-size: 0.9rem;
+ color: var(--muted-foreground);
+}
+
+.eq-range-input {
+ width: 60px;
+ 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-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 {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.eq-range-input[type='number'] {
+ -moz-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 {
+ 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-freq-controls label {
+ font-size: 0.9rem;
+ color: var(--muted-foreground);
+ font-weight: 500;
+}
+
+.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 {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.eq-freq-input[type='number'] {
+ -moz-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);
+}
+
+/* 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 {
display: flex;
justify-content: space-between;