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:
parent
31b6af317e
commit
eae8655877
5 changed files with 149 additions and 30 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue