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 + + +
-
- - 0 - 25 -
-
- - 0 - 40 -
-
- - 0 - 63 -
-
- - 0 - 100 -
-
- - 0 - 160 -
-
- - 0 - 250 -
-
- - 0 - 400 -
-
- - 0 - 630 -
-
- - 0 - 1K -
-
- - 0 - 1.6K -
-
- - 0 - 2.5K -
-
- - 0 - 4K -
-
- - 0 - 6.3K -
-
- - 0 - 10K -
-
- - 0 - 16K -
-
- - 0 - 20K -
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;