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