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
This commit is contained in:
tryptz 2026-04-06 22:41:49 -04:00 committed by edideaur
parent 31b6af317e
commit eae8655877
5 changed files with 149 additions and 30 deletions

View file

@ -4381,6 +4381,13 @@
>
Export
</button>
<button
id="legacy-geq-export-csv-btn"
class="btn-secondary"
title="Export EQ as Equalizer APO GraphicEQ config line"
>
Export APO
</button>
<input
type="file"
id="legacy-geq-import-file"

View file

@ -5,6 +5,41 @@
import { isIos } from './platform-detection.js';
import { equalizerSettings, monoAudioSettings } from './storage.js';
/**
* Compute RBJ cookbook IIR coefficients for shelf filters with Q support.
* Web Audio API's BiquadFilterNode ignores Q for lowshelf/highshelf,
* so we use IIRFilterNode with these coefficients instead.
*/
function computeShelfCoefficients(type, freq, gainDb, q, sampleRate) {
const A = Math.pow(10, gainDb / 40);
const w0 = (2 * Math.PI * freq) / sampleRate;
const alpha = Math.sin(w0) / (2 * q);
const cosW0 = Math.cos(w0);
const sqA = 2 * Math.sqrt(A) * alpha;
let b0, b1, b2, a0, a1, a2;
if (type === 'lowshelf') {
b0 = A * (A + 1 - (A - 1) * cosW0 + sqA);
b1 = 2 * A * (A - 1 - (A + 1) * cosW0);
b2 = A * (A + 1 - (A - 1) * cosW0 - sqA);
a0 = A + 1 + (A - 1) * cosW0 + sqA;
a1 = -2 * (A - 1 + (A + 1) * cosW0);
a2 = A + 1 + (A - 1) * cosW0 - sqA;
} else {
b0 = A * (A + 1 + (A - 1) * cosW0 + sqA);
b1 = -2 * A * (A - 1 + (A + 1) * cosW0);
b2 = A * (A + 1 + (A - 1) * cosW0 - sqA);
a0 = A + 1 - (A - 1) * cosW0 + sqA;
a1 = 2 * (A - 1 - (A + 1) * cosW0);
a2 = A + 1 - (A - 1) * cosW0 - sqA;
}
return {
feedforward: [b0 / a0, b1 / a0, b2 / a0],
feedback: [1, a1 / a0, a2 / a0],
};
}
// Generate frequency array for given number of bands using logarithmic spacing
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
const frequencies = [];
@ -245,14 +280,24 @@ class AudioContextManager {
const gainValue = Math.pow(10, preampValue / 20);
this.preampNode.gain.value = gainValue;
// Create biquad filters for each frequency band
// Create filters for each frequency band
this.filters = this.frequencies.map((freq, index) => {
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');

View file

@ -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);

View file

@ -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');

View file

@ -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]),
});
}
}