90 lines
3.0 KiB
JavaScript
90 lines
3.0 KiB
JavaScript
import { dbToLinear } from "./math.js";
|
|
|
|
export const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
|
export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
|
export const DEFAULT_MAX_COMPENSATION_DB = 24;
|
|
export const MAX_COMPENSATION_DB = 120;
|
|
|
|
export function requireMaxCompensationDb(
|
|
value = DEFAULT_MAX_COMPENSATION_DB,
|
|
label = "maxCompensationDb",
|
|
) {
|
|
if (!Number.isFinite(value) || value < 0 || value > MAX_COMPENSATION_DB) {
|
|
throw new RangeError(`${label} must lie inside 0..${MAX_COMPENSATION_DB} dB.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Return the inverse response gain in dB while limiting only positive boost.
|
|
* Attenuation remains exact because it cannot amplify a response notch or the
|
|
* microphone self-noise that occupies it.
|
|
*/
|
|
export function inverseResponseGainDb(responseGainDb, maxCompensationDb) {
|
|
if (!Number.isFinite(responseGainDb)) {
|
|
throw new RangeError("responseGainDb must be finite.");
|
|
}
|
|
return Math.min(
|
|
-responseGainDb,
|
|
requireMaxCompensationDb(maxCompensationDb),
|
|
);
|
|
}
|
|
|
|
function phaseAt(point) {
|
|
return point.phaseDeg ?? 0;
|
|
}
|
|
|
|
function wrapPhaseDeg(value) {
|
|
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
|
|
return Object.is(wrapped, -0) ? 0 : wrapped;
|
|
}
|
|
|
|
function writeResponse(output, gainDb, phaseDeg) {
|
|
output.gainDb = gainDb;
|
|
output.phaseDeg = wrapPhaseDeg(phaseDeg);
|
|
output.linearGain = dbToLinear(gainDb);
|
|
output.phaseRadians = (output.phaseDeg * Math.PI) / 180;
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Piecewise-linear microphone response interpolation.
|
|
*
|
|
* Frequencies outside the measured curve use the nearest endpoint. Phase follows
|
|
* the shortest wrapped path between points, avoiding a discontinuity at ±180°.
|
|
* Passing an output object lets FFT loops reuse storage and avoid per-bin garbage.
|
|
*/
|
|
export function interpolateFrequencyResponse(points, frequencyHz, output = {}) {
|
|
if (!Number.isFinite(frequencyHz) || frequencyHz < 0) {
|
|
throw new RangeError(`frequencyHz must be a finite non-negative number: ${frequencyHz}`);
|
|
}
|
|
if (points === undefined || points === null || points.length === 0) {
|
|
return writeResponse(output, 0, 0);
|
|
}
|
|
if (!Array.isArray(points)) throw new TypeError("frequency response points must be an array.");
|
|
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
if (frequencyHz <= first.frequencyHz) {
|
|
return writeResponse(output, first.gainDb, phaseAt(first));
|
|
}
|
|
if (frequencyHz >= last.frequencyHz) {
|
|
return writeResponse(output, last.gainDb, phaseAt(last));
|
|
}
|
|
|
|
let low = 0;
|
|
let high = points.length - 1;
|
|
while (high - low > 1) {
|
|
const middle = Math.floor((low + high) / 2);
|
|
if (frequencyHz < points[middle].frequencyHz) high = middle;
|
|
else low = middle;
|
|
}
|
|
const left = points[low];
|
|
const right = points[high];
|
|
const ratio = (frequencyHz - left.frequencyHz) / (right.frequencyHz - left.frequencyHz);
|
|
const gainDb = left.gainDb + (right.gainDb - left.gainDb) * ratio;
|
|
const leftPhase = phaseAt(left);
|
|
const phaseDelta = wrapPhaseDeg(phaseAt(right) - leftPhase);
|
|
return writeResponse(output, gainDb, leftPhase + phaseDelta * ratio);
|
|
}
|