270 lines
9.6 KiB
JavaScript
270 lines
9.6 KiB
JavaScript
// js/autoeq-engine.js
|
|
// AutoEQ Algorithm - Ported from Seap Engine AutoEqEngine.ts
|
|
// Iterative peak-flattening parametric EQ optimization
|
|
|
|
// Constants
|
|
const MAX_BOOST = 30.0;
|
|
const MAX_CUT = 30.0;
|
|
const MIN_Q = 0.6;
|
|
const DEFAULT_SR = 48000;
|
|
const PI = Math.PI;
|
|
const DB_BASE = 10;
|
|
const DB_DIVISOR = 40;
|
|
|
|
/**
|
|
* Calculate biquad filter magnitude response at a given frequency
|
|
* @param {number} f - Frequency to evaluate (Hz)
|
|
* @param {object} band - EQ band {type, freq, gain, q, enabled}
|
|
* @param {number} sr - Sample rate
|
|
* @returns {number} Magnitude in dB
|
|
*/
|
|
function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
|
if (!band.enabled) return 0;
|
|
if (!band.type || band.type.length === 0) return 0;
|
|
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 s = Math.sin(w) / (2 * effectiveQ);
|
|
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
|
const c = Math.cos(w);
|
|
let b0 = 0,
|
|
b1 = 0,
|
|
b2 = 0,
|
|
a0 = 0,
|
|
a1 = 0,
|
|
a2 = 0;
|
|
|
|
if (t === 'p') {
|
|
b0 = 1 + s * A;
|
|
b1 = -2 * c;
|
|
b2 = 1 - s * A;
|
|
a0 = 1 + s / A;
|
|
a1 = -2 * c;
|
|
a2 = 1 - s / A;
|
|
} else if (t === 'l') {
|
|
const sq = 2 * Math.sqrt(A) * s;
|
|
b0 = A * (A + 1 - (A - 1) * c + sq);
|
|
b1 = 2 * A * (A - 1 - (A + 1) * c);
|
|
b2 = A * (A + 1 - (A - 1) * c - sq);
|
|
a0 = A + 1 + (A - 1) * c + sq;
|
|
a1 = -2 * (A - 1 + (A + 1) * c);
|
|
a2 = A + 1 + (A - 1) * c - sq;
|
|
} else if (t === 'h') {
|
|
const sq = 2 * Math.sqrt(A) * s;
|
|
b0 = A * (A + 1 + (A - 1) * c + sq);
|
|
b1 = -2 * A * (A - 1 + (A + 1) * c);
|
|
b2 = A * (A + 1 + (A - 1) * c - sq);
|
|
a0 = A + 1 - (A - 1) * c + sq;
|
|
a1 = 2 * (A - 1 - (A + 1) * c);
|
|
a2 = A + 1 - (A - 1) * c - sq;
|
|
} else {
|
|
return 0;
|
|
}
|
|
|
|
const _a0 = 1 / a0;
|
|
const b0n = b0 * _a0,
|
|
b1n = b1 * _a0,
|
|
b2n = b2 * _a0;
|
|
const a1n = a1 * _a0,
|
|
a2n = a2 * _a0;
|
|
const cp = Math.cos(p),
|
|
c2p = Math.cos(2 * p);
|
|
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
|
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
|
return 10 * Math.log10(n / d);
|
|
}
|
|
|
|
/**
|
|
* Linear interpolation on frequency response data
|
|
* @param {number} freq - Frequency to interpolate at
|
|
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
|
* @returns {number} Interpolated gain value
|
|
*/
|
|
function interpolate(freq, data) {
|
|
if (data.length === 0) return 0;
|
|
if (freq <= data[0].freq) return data[0].gain;
|
|
if (freq >= data[data.length - 1].freq) return data[data.length - 1].gain;
|
|
for (let i = 0; i < data.length - 1; i++) {
|
|
if (freq >= data[i].freq && freq <= data[i + 1].freq) {
|
|
return (
|
|
data[i].gain +
|
|
((freq - data[i].freq) / (data[i + 1].freq - data[i].freq)) * (data[i + 1].gain - data[i].gain)
|
|
);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Calculate normalization offset based on midrange average (250-2500 Hz)
|
|
* With one argument: returns the midrange average of that curve (for graph centering).
|
|
* With two arguments: evaluates both curves on the measurement frequency grid
|
|
* to avoid sampling-density bias, returning (avgTarget - avgMeasurement).
|
|
* @param {Array<{freq: number, gain: number}>} measurement - Measurement/data curve
|
|
* @param {Array<{freq: number, gain: number}>} [target] - Optional target curve
|
|
* @returns {number} Midrange average, or alignment offset when target is provided
|
|
*/
|
|
function getNormalizationOffset(measurement, target) {
|
|
if (!target) {
|
|
let sum = 0,
|
|
count = 0;
|
|
for (const p of measurement) {
|
|
if (p.freq >= 250 && p.freq <= 2500) {
|
|
sum += p.gain;
|
|
count++;
|
|
}
|
|
}
|
|
return count > 0 ? sum / count : interpolate(1000, measurement);
|
|
}
|
|
let sumTarget = 0,
|
|
sumMeasurement = 0,
|
|
count = 0;
|
|
for (const p of measurement) {
|
|
if (p.freq >= 250 && p.freq <= 2500) {
|
|
sumTarget += interpolate(p.freq, target);
|
|
sumMeasurement += p.gain;
|
|
count++;
|
|
}
|
|
}
|
|
if (count > 0) return sumTarget / count - sumMeasurement / count;
|
|
return interpolate(1000, target) - interpolate(1000, measurement);
|
|
}
|
|
|
|
/**
|
|
* Run the AutoEQ algorithm to generate parametric EQ bands
|
|
* Iterative peak-flattening: finds largest error, places a corrective filter, repeats
|
|
*
|
|
* @param {Array<{freq: number, gain: number}>} measurement - Headphone frequency response
|
|
* @param {Array<{freq: number, gain: number}>} target - Target frequency response curve
|
|
* @param {number} bandCount - Number of EQ bands to generate
|
|
* @param {number} maxFreq - Maximum frequency limit (Hz)
|
|
* @param {number} minFreq - Minimum frequency limit (Hz)
|
|
* @param {number} maxQ - Maximum Q factor
|
|
* @returns {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>}
|
|
*/
|
|
function runAutoEqAlgorithm(
|
|
measurement,
|
|
target,
|
|
bandCount,
|
|
maxFreq = 16000,
|
|
minFreq = 20,
|
|
maxQ = 5.0,
|
|
sampleRate = DEFAULT_SR
|
|
) {
|
|
if (minFreq > maxFreq) return [];
|
|
const off = getNormalizationOffset(measurement, target);
|
|
let err = measurement.map((p) => ({ freq: p.freq, gain: p.gain + off - interpolate(p.freq, target) }));
|
|
|
|
const hasInRangePoints = err.some((p) => p.freq >= minFreq && p.freq <= maxFreq);
|
|
if (!hasInRangePoints) return [];
|
|
|
|
const out = [];
|
|
|
|
for (let i = 0; i < bandCount; i++) {
|
|
let maxDev = 0,
|
|
maxWeightedDev = 0,
|
|
peakFreq = 1000,
|
|
peakIdx = 0;
|
|
|
|
// Scan for maximum weighted error
|
|
for (let j = 0; j < err.length; j++) {
|
|
const p = err[j];
|
|
if (p.freq < minFreq || p.freq > maxFreq) continue;
|
|
|
|
// 3-point smoothing
|
|
let v = p.gain;
|
|
if (j > 0 && j < err.length - 1) {
|
|
v = (err[j - 1].gain + v + err[j + 1].gain) / 3;
|
|
}
|
|
|
|
// Frequency-dependent weighting
|
|
let w = 1.0;
|
|
if (p.freq < 300) w = 1.5;
|
|
else if (p.freq < 4000) w = 1.0;
|
|
else if (p.freq < 8000) w = 0.5;
|
|
else w = 0.25;
|
|
|
|
if (Math.abs(v * w) > Math.abs(maxWeightedDev)) {
|
|
maxWeightedDev = Math.abs(v * w);
|
|
maxDev = v;
|
|
peakFreq = p.freq;
|
|
peakIdx = j;
|
|
}
|
|
}
|
|
|
|
let gain = -maxDev;
|
|
|
|
// Safety clamps - reduce max boost at higher frequencies
|
|
let safeBoost = MAX_BOOST;
|
|
if (peakFreq > 3000) safeBoost = 6.0;
|
|
if (peakFreq > 6000) safeBoost = 3.0;
|
|
if (gain > safeBoost) gain = safeBoost;
|
|
if (gain < -MAX_CUT) gain = -MAX_CUT;
|
|
if (Math.abs(gain) < 0.2) break;
|
|
|
|
// Q factor calculation from error bandwidth (half-gain points)
|
|
let upperFreq = peakFreq,
|
|
lowerFreq = peakFreq;
|
|
let foundLower = false,
|
|
foundUpper = false;
|
|
const thresholdError = maxDev / 2;
|
|
for (let k = peakIdx; k >= 0; k--) {
|
|
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
|
lowerFreq = err[k].freq;
|
|
foundLower = true;
|
|
break;
|
|
}
|
|
}
|
|
for (let k = peakIdx; k < err.length; k++) {
|
|
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
|
upperFreq = err[k].freq;
|
|
foundUpper = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If half-gain boundary not found on one side, mirror the other side
|
|
// to avoid degenerate bandwidth = 0 producing extremely narrow filters
|
|
if (!foundLower && foundUpper) {
|
|
lowerFreq = (peakFreq * peakFreq) / upperFreq;
|
|
} else if (!foundUpper && foundLower) {
|
|
upperFreq = (peakFreq * peakFreq) / lowerFreq;
|
|
} else if (!foundLower && !foundUpper) {
|
|
// Neither boundary found - use 1 octave default
|
|
lowerFreq = peakFreq / Math.SQRT2;
|
|
upperFreq = peakFreq * Math.SQRT2;
|
|
}
|
|
|
|
let bandwidth = Math.log2(upperFreq / Math.max(1, lowerFreq));
|
|
if (bandwidth < 0.1) bandwidth = 0.1;
|
|
let q = Math.sqrt(Math.pow(2, bandwidth)) / (Math.pow(2, bandwidth) - 1);
|
|
q = Math.max(MIN_Q, Math.min(maxQ, q));
|
|
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
|
if (gain > 0 && q > 2.0) q = 2.0;
|
|
|
|
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
|
|
|
// Check cumulative gain at the peak frequency across all existing bands + this one
|
|
let cumulativeGain = gain;
|
|
for (const existing of out) {
|
|
cumulativeGain += calculateBiquadResponse(peakFreq, existing, sampleRate);
|
|
}
|
|
// If cumulative boost exceeds safe limits, reduce this band's gain
|
|
const cumulativeLimit = MAX_BOOST;
|
|
if (cumulativeGain > cumulativeLimit) {
|
|
newBand.gain = gain - (cumulativeGain - cumulativeLimit);
|
|
if (newBand.gain < 0.2) continue;
|
|
}
|
|
|
|
out.push(newBand);
|
|
|
|
// Update error curve by applying the new band's response
|
|
err = err.map((p) => ({ ...p, gain: p.gain + calculateBiquadResponse(p.freq, newBand, sampleRate) }));
|
|
}
|
|
|
|
return out.sort((a, b) => a.freq - b.freq).map((b, i) => ({ ...b, id: i }));
|
|
}
|
|
|
|
export { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm };
|