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
|
Export
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-export-csv-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ as Equalizer APO GraphicEQ config line"
|
||||||
|
>
|
||||||
|
Export APO
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="legacy-geq-import-file"
|
id="legacy-geq-import-file"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,41 @@
|
||||||
import { isIos } from './platform-detection.js';
|
import { isIos } from './platform-detection.js';
|
||||||
import { equalizerSettings, monoAudioSettings } from './storage.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
|
// Generate frequency array for given number of bands using logarithmic spacing
|
||||||
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
const frequencies = [];
|
const frequencies = [];
|
||||||
|
|
@ -245,14 +280,24 @@ class AudioContextManager {
|
||||||
const gainValue = Math.pow(10, preampValue / 20);
|
const gainValue = Math.pow(10, preampValue / 20);
|
||||||
this.preampNode.gain.value = gainValue;
|
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) => {
|
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();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
filter.type = type;
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value =
|
filter.Q.value = q;
|
||||||
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
filter.gain.value = gain;
|
||||||
filter.gain.value = this.currentGains[index] || 0;
|
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -843,12 +888,41 @@ class AudioContextManager {
|
||||||
// If filter count matches, update params in-place (no graph rebuild)
|
// If filter count matches, update params in-place (no graph rebuild)
|
||||||
if (this.filters.length === count) {
|
if (this.filters.length === count) {
|
||||||
const now = this.audioContext.currentTime;
|
const now = this.audioContext.currentTime;
|
||||||
|
let needsReconnect = false;
|
||||||
this.filters.forEach((filter, i) => {
|
this.filters.forEach((filter, i) => {
|
||||||
filter.type = newTypes[i] || 'peaking';
|
const type = newTypes[i] || 'peaking';
|
||||||
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
|
const q = newQs[i] > 0 ? newQs[i] : this._calculateQ(i);
|
||||||
filter.gain.setTargetAtTime(newGains[i], now, 0.005);
|
const isShelf = type === 'lowshelf' || type === 'highshelf';
|
||||||
filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
|
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 {
|
} else {
|
||||||
// Band count changed — must rebuild
|
// Band count changed — must rebuild
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
|
|
@ -872,10 +946,16 @@ class AudioContextManager {
|
||||||
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
||||||
sortedBands.forEach((band, index) => {
|
sortedBands.forEach((band, index) => {
|
||||||
if (index >= count) return;
|
if (index >= count) return;
|
||||||
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
lines.push(
|
if (band.type === 'lowshelf' || band.type === 'highshelf') {
|
||||||
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
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');
|
return lines.join('\n');
|
||||||
|
|
@ -893,12 +973,18 @@ class AudioContextManager {
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(
|
if (type === 'lowshelf' || type === 'highshelf') {
|
||||||
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
|
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');
|
return lines.join('\n');
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||||
const w = (2 * PI * band.freq) / sr;
|
const w = (2 * PI * band.freq) / sr;
|
||||||
const p = (2 * PI * f) / sr;
|
const p = (2 * PI * f) / sr;
|
||||||
const t = band.type[0];
|
const t = band.type[0];
|
||||||
// WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
|
const effectiveQ = band.q;
|
||||||
const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
|
|
||||||
const s = Math.sin(w) / (2 * effectiveQ);
|
const s = Math.sin(w) / (2 * effectiveQ);
|
||||||
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||||
const c = Math.cos(w);
|
const c = Math.cos(w);
|
||||||
|
|
|
||||||
|
|
@ -621,9 +621,17 @@ export class Equalizer {
|
||||||
|
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
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;
|
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');
|
return lines.join('\n');
|
||||||
|
|
|
||||||
|
|
@ -1385,14 +1385,19 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
// Legacy EQ Import / Export
|
// Legacy EQ Import / Export
|
||||||
const parseGeqLabelFrequency = (label) => {
|
const parseGeqLabelFrequency = (label) => {
|
||||||
const normalized = String(label).trim().toLowerCase().replace(/hz$/, '').trim();
|
const normalized = String(label).trim().toLowerCase().replace(/\s+/g, '');
|
||||||
if (normalized.endsWith('k')) {
|
if (normalized.endsWith('khz')) {
|
||||||
return Number.parseFloat(normalized.slice(0, -1)) * 1000;
|
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 GEQ_FREQUENCIES = GEQ_LABELS.map((label) => parseGeqLabelFrequency(label));
|
||||||
const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn');
|
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 legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn');
|
||||||
const legacyGeqImportFile = document.getElementById('legacy-geq-import-file');
|
const legacyGeqImportFile = document.getElementById('legacy-geq-import-file');
|
||||||
|
|
||||||
|
|
@ -1408,7 +1413,21 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'legacy-eq.txt';
|
a.download = 'legacy-eq.txt';
|
||||||
a.click();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
// Simple two-column format: freq gain (whitespace/tab/comma separated)
|
// 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) {
|
if (simpleMatch) {
|
||||||
importedPoints.push({
|
importedPoints.push({
|
||||||
freq: parseGeqLabelFrequency(simpleMatch[1]),
|
freq: parseGeqLabelFrequency(`${simpleMatch[1]}${simpleMatch[2] || ''}`),
|
||||||
gain: parseFloat(simpleMatch[2]),
|
gain: parseFloat(simpleMatch[3]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue