EQ changes
This commit is contained in:
parent
2fce3e382e
commit
f20f3dbb9d
6 changed files with 774 additions and 17 deletions
57
index.html
57
index.html
|
|
@ -3770,6 +3770,28 @@
|
|||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="eq-export-btn"
|
||||
class="btn-secondary"
|
||||
title="Export EQ settings to text"
|
||||
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
id="eq-import-btn"
|
||||
class="btn-secondary"
|
||||
title="Import EQ settings from text or file"
|
||||
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="eq-import-file"
|
||||
accept=".txt"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="custom-preset-controls">
|
||||
|
|
@ -3889,10 +3911,41 @@
|
|||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="eq-preamp-controls"
|
||||
style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem"
|
||||
>
|
||||
<label style="font-size: 0.75rem; opacity: 0.8">Preamp:</label>
|
||||
<input
|
||||
type="range"
|
||||
id="eq-preamp-slider"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
style="flex: 1; max-width: 120px"
|
||||
title="Preamp gain in dB"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-preamp-input"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
style="width: 60px; padding: 0.25rem; font-size: 0.75rem"
|
||||
title="Preamp value in dB"
|
||||
/>
|
||||
<span style="font-size: 0.75rem">dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="equalizer-bands" id="equalizer-bands">
|
||||
<!-- Bands will be dynamically generated by JavaScript -->
|
||||
<div class="equalizer-bands-wrapper">
|
||||
<canvas id="eq-response-canvas" class="eq-response-canvas"></canvas>
|
||||
<div class="equalizer-bands" id="equalizer-bands">
|
||||
<!-- Bands will be dynamically generated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="equalizer-scale">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
138
js/equalizer.js
138
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
|
||||
|
|
|
|||
333
js/settings.js
333
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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
88
styles.css
88
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue