From eae8655877b724e27ed2e262d4d7809d5ba6a525 Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 22:41:49 -0400 Subject: [PATCH] feat: add APO export for legacy EQ and fix shelf filter Q support - Add Export APO button to legacy graphic EQ (GraphicEQ config line format) - Fix shelf filters ignoring Q: use IIR filters with RBJ cookbook coefficients - Update graph visualization to use actual Q for shelf curves - Omit Q from shelf filters in all EQ text exports --- index.html | 7 +++ js/audio-context.js | 122 +++++++++++++++++++++++++++++++++++++------- js/autoeq-engine.js | 3 +- js/equalizer.js | 12 ++++- js/settings.js | 35 ++++++++++--- 5 files changed, 149 insertions(+), 30 deletions(-) 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]), }); } }