diff --git a/index.html b/index.html index 5925d9a..fcd7af9 100644 --- a/index.html +++ b/index.html @@ -3770,6 +3770,28 @@ + + +
@@ -3889,10 +3911,41 @@ Reset
+ +
+ + + + dB +
-
- +
+ +
+ +
diff --git a/js/audio-context.js b/js/audio-context.js index 666e810..7e3670f 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -142,6 +142,8 @@ class AudioContextManager { if (this.isInitialized && this.audioContext) { this._destroyEQ(); this._createEQ(); + // Reconnect the audio graph without interrupting playback + this._connectGraph(); } // Dispatch event for UI update @@ -177,6 +179,8 @@ class AudioContextManager { if (this.isInitialized && this.audioContext) { this._destroyEQ(); this._createEQ(); + // Reconnect the audio graph without interrupting playback + this._connectGraph(); } // Dispatch event for UI update @@ -203,6 +207,16 @@ class AudioContextManager { }); } this.filters = []; + + // Destroy preamp node + if (this.preampNode) { + try { + this.preampNode.disconnect(); + } catch { + /* ignore */ + } + this.preampNode = null; + } } /** @@ -211,6 +225,15 @@ class AudioContextManager { _createEQ() { if (!this.audioContext) return; + // Create preamp node + if (!this.preampNode) { + this.preampNode = this.audioContext.createGain(); + } + // Set preamp gain + const preampValue = this.preamp || 0; + const gainValue = Math.pow(10, preampValue / 20); + this.preampNode.gain.value = gainValue; + // Create biquad filters for each frequency band this.filters = this.frequencies.map((freq, index) => { const filter = this.audioContext.createBiquadFilter(); @@ -366,13 +389,18 @@ class AudioContextManager { } if (this.isEQEnabled && this.filters.length > 0) { - // EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination + // EQ enabled: lastNode -> preamp -> 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]); + // Connect preamp to first filter + if (this.preampNode) { + lastNode.connect(this.preampNode); + this.preampNode.connect(this.filters[0]); + } else { + lastNode.connect(this.filters[0]); + } this.filters[this.filters.length - 1].connect(this.outputNode); this.outputNode.connect(this.analyser); this.analyser.connect(this.volumeNode); @@ -609,6 +637,119 @@ class AudioContextManager { this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max); this.currentGains = equalizerSettings.getGains(this.bandCount); this.isMonoAudioEnabled = monoAudioSettings.isEnabled(); + this.preamp = equalizerSettings.getPreamp(); + } + + /** + * Set preamp value in dB + * @param {number} db - Preamp value in dB (-20 to +20) + */ + setPreamp(db) { + const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0)); + this.preamp = clampedDb; + equalizerSettings.setPreamp(clampedDb); + + // Update preamp node if it exists + if (this.preampNode && this.audioContext) { + const gainValue = Math.pow(10, clampedDb / 20); + const now = this.audioContext.currentTime; + this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01); + } + } + + /** + * Get current preamp value + * @returns {number} Current preamp value in dB + */ + getPreamp() { + return this.preamp || 0; + } + + /** + * Export equalizer settings to text format + * @returns {string} Exported settings in text format + */ + exportEQToText() { + const lines = []; + const preampValue = this.getPreamp(); + lines.push(`Preamp: ${preampValue.toFixed(1)} dB`); + + this.frequencies.forEach((freq, index) => { + const gain = this.currentGains[index] || 0; + const filterNum = index + 1; + lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`); + }); + + return lines.join('\n'); + } + + /** + * Import equalizer settings from text format + * @param {string} text - Text format settings + * @returns {boolean} True if import was successful + */ + importEQFromText(text) { + try { + const lines = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line); + const filters = []; + let preamp = 0; + + for (const line of lines) { + // Parse preamp + const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i); + if (preampMatch) { + preamp = parseFloat(preampMatch[1]); + continue; + } + + // Parse filter lines (handle "Filter:" and "Filter X:" formats) + const filterMatch = line.match( + /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i + ); + if (filterMatch) { + const type = filterMatch[1].toUpperCase(); + const freq = parseInt(filterMatch[2], 10); + const gain = parseFloat(filterMatch[3]); + const q = parseFloat(filterMatch[4]); + filters.push({ type, freq, gain, q }); + } + } + + if (filters.length === 0) { + console.warn('[AudioContext] No valid filters found in import text'); + return false; + } + + // Apply preamp + this.setPreamp(preamp); + + // If different number of bands, adjust + if (filters.length !== this.bandCount) { + const newCount = Math.max( + equalizerSettings.MIN_BANDS, + Math.min(equalizerSettings.MAX_BANDS, filters.length) + ); + this.setBandCount(newCount); + } + + // Extract gains from filters + const gains = filters.slice(0, this.bandCount).map((f) => f.gain); + this.setAllGains(gains); + + // Store filter frequencies if different + const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); + if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { + equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); + } + + return true; + } catch (e) { + console.warn('[AudioContext] Failed to import EQ settings:', e); + return false; + } } } diff --git a/js/equalizer.js b/js/equalizer.js index 6c87bb6..09b70d8 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -174,6 +174,9 @@ export class Equalizer { // Store current gains this.currentGains = new Array(this.bandCount).fill(0); + // Store current preamp value + this.preamp = 0; + // Load saved settings this._loadSettings(); } @@ -290,6 +293,10 @@ export class Equalizer { this.inputNode = this.audioContext.createGain(); this.outputNode = this.audioContext.createGain(); + // Create preamp gain node + this.preampNode = this.audioContext.createGain(); + this._updatePreampGain(); + // Connect the filter chain this._connectFilters(); @@ -325,6 +332,11 @@ export class Equalizer { _connectFilters() { if (!this.filters.length) return; + // Connect preamp to first filter + if (this.preampNode) { + this.preampNode.connect(this.filters[0]); + } + // Chain filters together for (let i = 0; i < this.filters.length - 1; i++) { this.filters[i].connect(this.filters[i + 1]); @@ -356,7 +368,7 @@ export class Equalizer { * Get the input node for external connection */ getInputNode() { - return this.filters[0] || null; + return this.preampNode || this.filters[0] || null; } /** @@ -530,6 +542,38 @@ export class Equalizer { this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max); this.frequencyLabels = generateFrequencyLabels(this.frequencies); this.currentGains = equalizerSettings.getGains(this.bandCount); + this.preamp = equalizerSettings.getPreamp(); + } + + /** + * Update preamp gain value + * @private + */ + _updatePreampGain() { + if (this.preampNode && this.audioContext) { + const gainValue = Math.pow(10, this.preamp / 20); + const now = this.audioContext.currentTime; + this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01); + } + } + + /** + * Set preamp value in dB + * @param {number} db - Preamp value in dB (-20 to +20) + */ + setPreamp(db) { + const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0)); + this.preamp = clampedDb; + equalizerSettings.setPreamp(clampedDb); + this._updatePreampGain(); + } + + /** + * Get current preamp value + * @returns {number} Current preamp value in dB + */ + getPreamp() { + return this.preamp; } /** @@ -554,12 +598,104 @@ export class Equalizer { } catch { /* ignore */ } + try { + this.preampNode?.disconnect(); + } catch { + /* ignore */ + } this.filters = []; this.inputNode = null; this.outputNode = null; + this.preampNode = null; this.isInitialized = false; } + + /** + * Export equalizer settings to text format + * @returns {string} Exported settings in text format + */ + exportToText() { + const lines = []; + lines.push(`Preamp: ${this.preamp.toFixed(1)} dB`); + + this.frequencies.forEach((freq, index) => { + const gain = this.currentGains[index] || 0; + const filterNum = index + 1; + lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`); + }); + + return lines.join('\n'); + } + + /** + * Import equalizer settings from text format + * @param {string} text - Text format settings + * @returns {boolean} True if import was successful + */ + importFromText(text) { + try { + const lines = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line); + const filters = []; + let preamp = 0; + + for (const line of lines) { + // Parse preamp + const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i); + if (preampMatch) { + preamp = parseFloat(preampMatch[1]); + continue; + } + + // Parse filter lines (handle "Filter:" and "Filter X:" formats) + const filterMatch = line.match( + /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i + ); + if (filterMatch) { + const type = filterMatch[1].toUpperCase(); + const freq = parseInt(filterMatch[2], 10); + const gain = parseFloat(filterMatch[3]); + const q = parseFloat(filterMatch[4]); + filters.push({ type, freq, gain, q }); + } + } + + if (filters.length === 0) { + console.warn('[Equalizer] No valid filters found in import text'); + return false; + } + + // Apply preamp + this.setPreamp(preamp); + + // If different number of bands, adjust + if (filters.length !== this.bandCount) { + const newCount = Math.max( + equalizerSettings.MIN_BANDS, + Math.min(equalizerSettings.MAX_BANDS, filters.length) + ); + this.setBandCount(newCount); + } + + // Extract gains from filters + const gains = filters.slice(0, this.bandCount).map((f) => f.gain); + this.setAllGains(gains); + + // Store filter frequencies if different + const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); + if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { + equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); + } + + return true; + } catch (e) { + console.warn('[Equalizer] Failed to import settings:', e); + return false; + } + } } // Export singleton instance diff --git a/js/settings.js b/js/settings.js index b4832b3..1f71e5a 100644 --- a/js/settings.js +++ b/js/settings.js @@ -886,11 +886,17 @@ export function initializeSettings(scrobbler, player, api, ui) { const resetEqFreqBtn = document.getElementById('reset-eq-freq-btn'); const resetEqRangeBtn = document.getElementById('reset-eq-range-btn'); const eqScaleContainer = document.querySelector('.equalizer-scale'); + const eqPreampSlider = document.getElementById('eq-preamp-slider'); + const eqPreampInput = document.getElementById('eq-preamp-input'); + const eqExportBtn = document.getElementById('eq-export-btn'); + const eqImportBtn = document.getElementById('eq-import-btn'); + const eqImportFile = document.getElementById('eq-import-file'); // Current settings let currentBandCount = equalizerSettings.getBandCount(); let currentRange = equalizerSettings.getRange(); let currentFreqRange = equalizerSettings.getFreqRange(); + let currentPreamp = equalizerSettings.getPreamp(); /** * Generate frequency labels for given band count and frequency range @@ -1004,6 +1010,9 @@ export function initializeSettings(scrobbler, player, api, ui) { updateBandValueDisplay(bandEl, gains[index]); } }); + + // Redraw the EQ curve after updating all bands + drawEQCurve(); }; /** @@ -1012,6 +1021,10 @@ export function initializeSettings(scrobbler, player, api, ui) { const updateEQContainerVisibility = (enabled) => { if (eqContainer) { eqContainer.style.display = enabled ? 'block' : 'none'; + if (enabled) { + // Redraw curve when container becomes visible + requestAnimationFrame(drawEQCurve); + } } }; @@ -1060,6 +1073,152 @@ export function initializeSettings(scrobbler, player, api, ui) { deleteCustomPresetBtn.style.display = isCustom ? 'flex' : 'none'; }; + /** + * Draw smooth EQ response curve on canvas + */ + const drawEQCurve = () => { + const canvas = document.getElementById('eq-response-canvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + + // Skip if canvas has no size (not visible yet) + if (rect.width === 0 || rect.height === 0) return; + + // Set canvas size accounting for DPR + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const width = rect.width; + const height = rect.height; + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Get all current gain values + const eqBands = eqBandsContainer?.querySelectorAll('.eq-band'); + if (!eqBands || eqBands.length === 0) return; + + // Get the actual highlight color from CSS + const tempEl = document.createElement('div'); + tempEl.style.color = 'rgb(var(--highlight-rgb))'; + document.body.appendChild(tempEl); + const highlightColor = getComputedStyle(tempEl).color; + document.body.removeChild(tempEl); + + const gains = []; + const positions = []; + const range = currentRange; + const rangeTotal = range.max - range.min; + const canvasRect = canvas.getBoundingClientRect(); + + eqBands.forEach((bandEl) => { + const slider = bandEl.querySelector('.eq-slider'); + const gain = slider ? parseFloat(slider.value) : 0; + gains.push(gain); + + // Get actual center position of the band element relative to canvas + const bandRect = bandEl.getBoundingClientRect(); + const x = bandRect.left + bandRect.width / 2 - canvasRect.left; + positions.push(x); + }); + + // Calculate y positions - account for slider thumb size (18px) + // The track is 120px, but thumb center moves within (120 - 18) = 102px range + const trackHeight = height; + const thumbSize = 18; + const usableTrack = trackHeight - thumbSize; + const trackOffset = thumbSize / 2; + + const getY = (gain) => { + const normalized = (gain - range.min) / rangeTotal; + // Invert because canvas Y=0 is at top, slider max is at top + return trackOffset + (1 - normalized) * usableTrack; + }; + + // Create points array + const points = gains.map((gain, i) => ({ + x: positions[i], + y: getY(gain), + })); + + if (points.length < 2) return; + + // Parse RGB values from color string + const rgbMatch = highlightColor.match(/\d+/g); + const r = rgbMatch ? parseInt(rgbMatch[0]) : 128; + const g = rgbMatch ? parseInt(rgbMatch[1]) : 128; + const b = rgbMatch ? parseInt(rgbMatch[2]) : 128; + + // Calculate control points for smooth curve + const getControlPoints = (i) => { + const p0 = points[i === 0 ? i : i - 1]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] || p2; + + const cp1x = p1.x + (p2.x - p0.x) / 6; + const cp1y = p1.y + (p2.y - p0.y) / 6; + const cp2x = p2.x - (p3.x - p1.x) / 6; + const cp2y = p2.y - (p3.y - p1.y) / 6; + + return { cp1x, cp1y, cp2x, cp2y }; + }; + + // Draw filled area from curve to bottom + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.3)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.05)`); + + ctx.beginPath(); + ctx.moveTo(points[0].x, height); + ctx.lineTo(points[0].x, points[0].y); + + for (let i = 0; i < points.length - 1; i++) { + const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i); + const p2 = points[i + 1]; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + + ctx.lineTo(points[points.length - 1].x, height); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // Draw the curve line + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + + for (let i = 0; i < points.length - 1; i++) { + const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i); + const p2 = points[i + 1]; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + + ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Draw dots at each band point + points.forEach((point) => { + ctx.beginPath(); + ctx.arc(point.x, point.y, 4, 0, Math.PI * 2); + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx.fill(); + + // Add white center to dots for visibility + ctx.beginPath(); + ctx.arc(point.x, point.y, 2, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.fill(); + }); + }; + /** * Initialize band slider event listeners */ @@ -1084,6 +1243,7 @@ export function initializeSettings(scrobbler, player, api, ui) { const gain = parseFloat(e.target.value); audioContextManager.setBandGain(bandIndex, gain); updateBandValueDisplay(bandEl, gain); + drawEQCurve(); // When manually adjusting, check if we should clear preset if (eqPresetSelect && eqPresetSelect.value !== 'flat') { @@ -1104,9 +1264,15 @@ export function initializeSettings(scrobbler, player, api, ui) { slider.value = 0; audioContextManager.setBandGain(bandIndex, 0); updateBandValueDisplay(bandEl, 0); + drawEQCurve(); }); } }); + + // Initial curve draw with delay to ensure canvas has proper dimensions + setTimeout(() => { + drawEQCurve(); + }, 100); }; // Initialize EQ toggle @@ -1119,6 +1285,13 @@ export function initializeSettings(scrobbler, player, api, ui) { const enabled = e.target.checked; audioContextManager.toggleEQ(enabled); updateEQContainerVisibility(enabled); + + // Redraw curve after a brief delay to allow container to become visible + if (enabled) { + setTimeout(() => { + drawEQCurve(); + }, 50); + } }); } @@ -1131,9 +1304,9 @@ export function initializeSettings(scrobbler, player, api, ui) { if (newCount >= equalizerSettings.MIN_BANDS && newCount <= equalizerSettings.MAX_BANDS) { currentBandCount = newCount; - // Save new band count and update audio context + // Save new band count and update audio context (interpolates gains automatically) equalizerSettings.setBandCount(newCount); - audioContextManager.setBandCount?.(newCount) || audioContextManager.reinitialize?.(); + audioContextManager.setBandCount?.(newCount); // Regenerate UI generateEQBands( @@ -1144,16 +1317,25 @@ export function initializeSettings(scrobbler, player, api, ui) { currentFreqRange.max ); - // Reset to flat and apply - const flatGains = new Array(newCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); + // Get interpolated gains from audio context + const interpolatedGains = audioContextManager.getGains?.() || equalizerSettings.getGains(newCount); + updateAllBandUI(interpolatedGains); + // Keep current preset or set to custom if modified if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); + const currentPreset = eqPresetSelect.value; + if (!currentPreset.startsWith('custom_')) { + eqPresetSelect.value = 'custom'; + } } updateDeleteButtonVisibility(); + + // Show brief feedback + const originalText = eqBandCountInput.style.backgroundColor; + eqBandCountInput.style.backgroundColor = 'var(--highlight)'; + setTimeout(() => { + eqBandCountInput.style.backgroundColor = originalText; + }, 300); } }); } @@ -1490,6 +1672,125 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + // Initialize preamp control + const updatePreampUI = (value) => { + currentPreamp = value; + if (eqPreampSlider) eqPreampSlider.value = value; + if (eqPreampInput) eqPreampInput.value = value; + audioContextManager.setPreamp?.(value); + }; + + if (eqPreampSlider) { + // Set initial value + eqPreampSlider.value = currentPreamp; + + // Handle slider input + eqPreampSlider.addEventListener('input', (e) => { + const value = parseFloat(e.target.value); + updatePreampUI(value); + }); + } + + if (eqPreampInput) { + // Set initial value + eqPreampInput.value = currentPreamp; + + // Handle text input + eqPreampInput.addEventListener('change', (e) => { + let value = parseFloat(e.target.value); + // Clamp to valid range + value = Math.max(-20, Math.min(20, value || 0)); + updatePreampUI(value); + }); + + // Handle enter key + eqPreampInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.target.blur(); + } + }); + } + + // Initialize import/export controls + if (eqExportBtn) { + eqExportBtn.addEventListener('click', () => { + const text = audioContextManager.exportEQToText?.(); + if (text) { + navigator.clipboard + .writeText(text) + .then(() => { + eqExportBtn.textContent = 'Copied!'; + setTimeout(() => { + eqExportBtn.textContent = 'Export'; + }, 1500); + }) + .catch(() => { + // Fallback: create and download file + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'equalizer-settings.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + } + }); + } + + if (eqImportBtn && eqImportFile) { + eqImportBtn.addEventListener('click', () => { + eqImportFile.click(); + }); + + eqImportFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target.result; + const success = audioContextManager.importEQFromText?.(text); + if (success) { + // Update UI + currentPreamp = equalizerSettings.getPreamp(); + updatePreampUI(currentPreamp); + + // Update band count if changed + currentBandCount = equalizerSettings.getBandCount(); + if (eqBandCountInput) eqBandCountInput.value = currentBandCount; + + // Regenerate bands and update UI + generateEQBands( + currentBandCount, + currentRange.min, + currentRange.max, + currentFreqRange.min, + currentFreqRange.max + ); + const gains = audioContextManager.getGains?.() || equalizerSettings.getGains(currentBandCount); + updateAllBandUI(gains); + + eqImportBtn.textContent = 'Imported!'; + setTimeout(() => { + eqImportBtn.textContent = 'Import'; + }, 1500); + } else { + eqImportBtn.textContent = 'Invalid!'; + setTimeout(() => { + eqImportBtn.textContent = 'Import'; + }, 1500); + } + }; + reader.readAsText(file); + + // Reset file input + e.target.value = ''; + }); + } + // Generate initial EQ bands with current ranges generateEQBands(currentBandCount, currentRange.min, currentRange.max, currentFreqRange.min, currentFreqRange.max); @@ -1524,6 +1825,22 @@ export function initializeSettings(scrobbler, player, api, ui) { } }); + // Redraw EQ curve on window resize + window.addEventListener('resize', () => { + requestAnimationFrame(drawEQCurve); + }); + + // Redraw EQ curve when a new track loads (audio metadata loaded) + const audioPlayer = document.getElementById('audio-player'); + if (audioPlayer) { + audioPlayer.addEventListener('loadedmetadata', () => { + // Small delay to ensure the visualizer and EQ are fully ready + setTimeout(() => { + drawEQCurve(); + }, 100); + }); + } + // Now Playing Mode const nowPlayingMode = document.getElementById('now-playing-mode'); if (nowPlayingMode) { diff --git a/js/storage.js b/js/storage.js index 60548fe..0e5422a 100644 --- a/js/storage.js +++ b/js/storage.js @@ -797,6 +797,7 @@ export const equalizerSettings = { RANGE_MAX_KEY: 'equalizer-range-max', FREQ_MIN_KEY: 'equalizer-freq-min', FREQ_MAX_KEY: 'equalizer-freq-max', + PREAMP_KEY: 'equalizer-preamp', DEFAULT_BAND_COUNT: 16, MIN_BANDS: 3, MAX_BANDS: 32, @@ -808,6 +809,9 @@ export const equalizerSettings = { DEFAULT_FREQ_MAX: 20000, ABSOLUTE_FREQ_MIN: 10, ABSOLUTE_FREQ_MAX: 96000, + DEFAULT_PREAMP: 0, + PREAMP_MIN: -20, + PREAMP_MAX: 20, isEnabled() { try { @@ -967,6 +971,30 @@ export const equalizerSettings = { return validMin && validMax; }, + getPreamp() { + try { + const stored = localStorage.getItem(this.PREAMP_KEY); + if (stored) { + const val = parseFloat(stored); + if (!isNaN(val) && val >= this.PREAMP_MIN && val <= this.PREAMP_MAX) { + return val; + } + } + } catch { + /* ignore */ + } + return this.DEFAULT_PREAMP; + }, + + setPreamp(value) { + const val = parseFloat(value); + if (!isNaN(val) && val >= this.PREAMP_MIN && val <= this.PREAMP_MAX) { + localStorage.setItem(this.PREAMP_KEY, val.toString()); + return true; + } + return false; + }, + getGains(bandCount) { const count = bandCount || this.getBandCount(); try { diff --git a/styles.css b/styles.css index 6050c52..14a3992 100644 --- a/styles.css +++ b/styles.css @@ -6563,7 +6563,7 @@ textarea:focus { .equalizer-preset-row { display: flex; align-items: center; - gap: var(--spacing-md); + gap: var(--spacing-sm); flex-wrap: wrap; } @@ -6857,6 +6857,72 @@ textarea:focus { margin-left: var(--spacing-xs); } +/* EQ Preamp Controls */ +.eq-preamp-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-preamp-controls label { + font-size: 0.9rem; + color: var(--muted-foreground); + font-weight: 500; +} + +#eq-preamp-slider { + flex: 1; + min-width: 120px; + max-width: 200px; + height: 4px; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +#eq-preamp-slider::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + transition: transform var(--transition-fast); +} + +#eq-preamp-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +#eq-preamp-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + border: none; + transition: transform var(--transition-fast); +} + +#eq-preamp-slider::-moz-range-thumb:hover { + transform: scale(1.2); +} + +/* EQ Import/Export Buttons (now inline) */ +#eq-export-btn, +#eq-import-btn { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + white-space: nowrap; + gap: var(--spacing-xs); +} + /* Equalizer preset dropdown styling */ .equalizer-preset-row select optgroup { font-weight: 600; @@ -6869,11 +6935,15 @@ textarea:focus { padding-left: var(--spacing-sm); } +.equalizer-bands-wrapper { + position: relative; + padding: var(--spacing-md) 0; +} + .equalizer-bands { display: flex; justify-content: space-between; gap: 4px; - padding: var(--spacing-md) 0; position: relative; } @@ -6883,7 +6953,7 @@ textarea:focus { position: absolute; left: 0; right: 0; - top: calc(var(--spacing-md) + 60px); + top: 60px; height: 1px; background: var(--border); opacity: 0.5; @@ -6891,6 +6961,18 @@ textarea:focus { z-index: 0; } +/* EQ Response Curve Canvas */ +.eq-response-canvas { + position: absolute; + top: var(--spacing-md); + left: 4px; + width: calc(100% - 8px); + height: 120px; + pointer-events: none; + z-index: 2; + display: block; +} + .eq-band { display: flex; flex-direction: column;