diff --git a/index.html b/index.html
index db93876..aeeae68 100644
--- a/index.html
+++ b/index.html
@@ -4381,6 +4381,13 @@
>
Export
+
{
+ const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
+ const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
+ const gain = this.currentGains[index] || 0;
+
+ if (type === 'lowshelf' || type === 'highshelf') {
+ const coeffs = computeShelfCoefficients(type, freq, gain, q, this.audioContext.sampleRate);
+ const iir = this.audioContext.createIIRFilter(coeffs.feedforward, coeffs.feedback);
+ iir._shelfType = type;
+ return iir;
+ }
+
const filter = this.audioContext.createBiquadFilter();
- filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
+ filter.type = type;
filter.frequency.value = freq;
- filter.Q.value =
- this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
- filter.gain.value = this.currentGains[index] || 0;
+ filter.Q.value = q;
+ filter.gain.value = gain;
return filter;
});
@@ -843,12 +888,41 @@ class AudioContextManager {
// If filter count matches, update params in-place (no graph rebuild)
if (this.filters.length === count) {
const now = this.audioContext.currentTime;
+ let needsReconnect = false;
this.filters.forEach((filter, i) => {
- filter.type = newTypes[i] || 'peaking';
- filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
- filter.gain.setTargetAtTime(newGains[i], now, 0.005);
- filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
+ const type = newTypes[i] || 'peaking';
+ const q = newQs[i] > 0 ? newQs[i] : this._calculateQ(i);
+ const isShelf = type === 'lowshelf' || type === 'highshelf';
+ const wasShelf = !!filter._shelfType;
+
+ if (isShelf) {
+ // IIR filters can't update params — must replace the node
+ const coeffs = computeShelfCoefficients(type, newFrequencies[i], newGains[i], q, this.audioContext.sampleRate);
+ const iir = this.audioContext.createIIRFilter(coeffs.feedforward, coeffs.feedback);
+ iir._shelfType = type;
+ try { filter.disconnect(); } catch { /* ignore */ }
+ this.filters[i] = iir;
+ needsReconnect = true;
+ } else if (wasShelf) {
+ // Was shelf IIR, now peaking — create new BiquadFilterNode
+ const biquad = this.audioContext.createBiquadFilter();
+ biquad.type = type;
+ biquad.frequency.value = newFrequencies[i];
+ biquad.gain.value = newGains[i];
+ biquad.Q.value = q;
+ try { filter.disconnect(); } catch { /* ignore */ }
+ this.filters[i] = biquad;
+ needsReconnect = true;
+ } else {
+ filter.type = type;
+ filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
+ filter.gain.setTargetAtTime(newGains[i], now, 0.005);
+ filter.Q.setTargetAtTime(q, now, 0.005);
+ }
});
+ if (needsReconnect) {
+ this._connectGraph();
+ }
} else {
// Band count changed — must rebuild
this._destroyEQ();
@@ -872,10 +946,16 @@ class AudioContextManager {
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
sortedBands.forEach((band, index) => {
if (index >= count) return;
- const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
- lines.push(
- `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
- );
+ const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
+ if (band.type === 'lowshelf' || band.type === 'highshelf') {
+ lines.push(
+ `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB`
+ );
+ } else {
+ lines.push(
+ `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
+ );
+ }
});
return lines.join('\n');
@@ -893,12 +973,18 @@ class AudioContextManager {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
- const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
- const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
+ const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
const filterNum = index + 1;
- lines.push(
- `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
- );
+ if (type === 'lowshelf' || type === 'highshelf') {
+ lines.push(
+ `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB`
+ );
+ } else {
+ const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
+ lines.push(
+ `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
+ );
+ }
});
return lines.join('\n');
diff --git a/js/autoeq-engine.js b/js/autoeq-engine.js
index 2989313..bba9a4b 100644
--- a/js/autoeq-engine.js
+++ b/js/autoeq-engine.js
@@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
const w = (2 * PI * band.freq) / sr;
const p = (2 * PI * f) / sr;
const t = band.type[0];
- // WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
- const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
+ const effectiveQ = band.q;
const s = Math.sin(w) / (2 * effectiveQ);
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
const c = Math.cos(w);
diff --git a/js/equalizer.js b/js/equalizer.js
index 797934a..d8a9c72 100644
--- a/js/equalizer.js
+++ b/js/equalizer.js
@@ -621,9 +621,17 @@ export class Equalizer {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
- const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
+ const filter = this.filters[index];
+ const type = filter ? filter.type : 'peaking';
+ const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' };
+ const typeStr = typeMap[type] || 'PK';
const filterNum = index + 1;
- lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
+ if (type === 'lowshelf' || type === 'highshelf') {
+ lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB`);
+ } else {
+ const q = filter ? filter.Q.value : this._calculateQ(index);
+ lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
+ }
});
return lines.join('\n');
diff --git a/js/settings.js b/js/settings.js
index 670ead9..67ee468 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -1385,14 +1385,19 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Legacy EQ Import / Export
const parseGeqLabelFrequency = (label) => {
- const normalized = String(label).trim().toLowerCase().replace(/hz$/, '').trim();
- if (normalized.endsWith('k')) {
- return Number.parseFloat(normalized.slice(0, -1)) * 1000;
+ const normalized = String(label).trim().toLowerCase().replace(/\s+/g, '');
+ if (normalized.endsWith('khz')) {
+ return Number.parseFloat(normalized.slice(0, -3)) * 1000;
}
- return Number.parseFloat(normalized);
+ const withoutHz = normalized.replace(/hz$/, '');
+ if (withoutHz.endsWith('k')) {
+ return Number.parseFloat(withoutHz.slice(0, -1)) * 1000;
+ }
+ return Number.parseFloat(withoutHz);
};
const GEQ_FREQUENCIES = GEQ_LABELS.map((label) => parseGeqLabelFrequency(label));
const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn');
+ const legacyGeqExportCsvBtn = document.getElementById('legacy-geq-export-csv-btn');
const legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn');
const legacyGeqImportFile = document.getElementById('legacy-geq-import-file');
@@ -1408,7 +1413,21 @@ export async function initializeSettings(scrobbler, player, api, ui) {
a.href = url;
a.download = 'legacy-eq.txt';
a.click();
- URL.revokeObjectURL(url);
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ });
+ }
+
+ if (legacyGeqExportCsvBtn) {
+ legacyGeqExportCsvBtn.addEventListener('click', () => {
+ const pairs = GEQ_FREQUENCIES.map((freq, i) => `${freq} ${geqGains[i].toFixed(1)}`).join('; ');
+ const lines = [`Preamp: ${geqPreamp.toFixed(1)} dB`, `GraphicEQ: ${pairs}`];
+ const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'legacy-eq-apo.txt';
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
});
}
@@ -1445,11 +1464,11 @@ export async function initializeSettings(scrobbler, player, api, ui) {
continue;
}
// Simple two-column format: freq gain (whitespace/tab/comma separated)
- const simpleMatch = line.trim().match(/^([\d.]+[kK]?)\s*(?:Hz|kHz)?\s*[,\s\t]+([+-]?[\d.]+)/);
+ const simpleMatch = line.trim().match(/^([\d.]+)\s*([kK])?(?:Hz)?\s*[,\s\t]+([+-]?[\d.]+)/);
if (simpleMatch) {
importedPoints.push({
- freq: parseGeqLabelFrequency(simpleMatch[1]),
- gain: parseFloat(simpleMatch[2]),
+ freq: parseGeqLabelFrequency(`${simpleMatch[1]}${simpleMatch[2] || ''}`),
+ gain: parseFloat(simpleMatch[3]),
});
}
}