Files
controller-v2/client/src/lib/peqAudio.ts
T

545 lines
13 KiB
TypeScript

// ============================================================
// PEQ Audio Processing - Migrated from old/audio.js
// ============================================================
import * as echarts from 'echarts';
export const TYPE_PEAK = 4;
export const TYPE_LOWSHELF = 5;
export const TYPE_HIGHSHELF = 6;
export const TYPE_LOWPASS = 0;
export const TYPE_HIGHPASS = 1;
export const TYPE_BANDPASS = 2;
export const TYPE_NOTCH = 3;
export const TYPE_ALLPASS = 7;
// Filter type mapping (device uses numeric type; pass-through when already a number)
export function getFilterType(typeName: string | number): number {
if (typeof typeName === "number" && Number.isFinite(typeName)) {
return typeName;
}
switch (typeName) {
case 'LPF':
case 'LOW_PASS':
return TYPE_LOWPASS;
case 'HPF':
case 'HIGH_PASS':
return TYPE_HIGHPASS;
case 'BPF':
case 'BAND_PASS':
return TYPE_BANDPASS;
case 'NOTCH':
return TYPE_NOTCH;
case 'PEAK':
case 'PEAKING':
return TYPE_PEAK;
case 'LSHELF':
case 'LOW_SHELF':
return TYPE_LOWSHELF;
case 'HSHELF':
case 'HIGH_SHELF':
return TYPE_HIGHSHELF;
case 'APF':
case 'ALL_PASS':
return TYPE_ALLPASS;
default:
return TYPE_PEAK;
}
}
export function getFilterShortName(type: string): string {
switch (type) {
case 'LOW_PASS':
return 'LPF';
case 'HIGH_PASS':
return 'HPF';
case 'BAND_PASS':
return 'BPF';
case 'LOW_SHELF':
return 'LSHELF';
case 'HIGH_SHELF':
return 'HSHELF';
case 'ALL_PASS':
return 'APF';
case 'PEAKING':
return 'PEAK';
default:
return type;
}
}
// Coefficients class
class Coeff {
b0 = 0;
b1 = 0;
b2 = 0;
a0 = 0;
a1 = 0;
a2 = 0;
setB0(v: number) { this.b0 = v; }
setB1(v: number) { this.b1 = v; }
setB2(v: number) { this.b2 = v; }
setA0(v: number) { this.a0 = v; }
setA1(v: number) { this.a1 = v; }
setA2(v: number) { this.a2 = v; }
getB0() { return this.b0; }
getB1() { return this.b1; }
getB2() { return this.b2; }
getA0() { return this.a0; }
getA1() { return this.a1; }
getA2() { return this.a2; }
}
// Complex number class
class Complex {
constructor(public real: number, public image: number) {}
add(a: Complex): Complex {
return new Complex(this.real + a.real, this.image + a.image);
}
sub(a: Complex): Complex {
return new Complex(this.real - a.real, this.image - a.image);
}
div(a: Complex): Complex {
const denominator = a.real * a.real + a.image * a.image;
const newReal = (this.real * a.real + this.image * a.image) / denominator;
const newImage = (this.image * a.real - this.real * a.image) / denominator;
return new Complex(newReal, newImage);
}
mul(a: Complex): Complex {
const newReal = this.real * a.real - this.image * a.image;
const newImage = this.image * a.real + this.real * a.image;
return new Complex(newReal, newImage);
}
mulScalar(a: number): Complex {
return new Complex(this.real * a, this.image * a);
}
addScalar(a: number): Complex {
return new Complex(this.real + a, this.image);
}
}
function getAmplitude(complex: Complex): number {
return Math.sqrt(Math.pow(complex.real, 2) + Math.pow(complex.image, 2));
}
function getFreqw(coeff: Coeff, w: number): Complex {
const complex = new Complex(Math.cos(w), -Math.sin(w));
const b2 = (complex.mul(complex)).mulScalar(coeff.getB2());
const b1 = complex.mulScalar(coeff.getB1());
const hup = b2.add(b1).addScalar(coeff.getB0());
const a2 = (complex.mul(complex)).mulScalar(coeff.getA2());
const a1 = complex.mulScalar(coeff.getA1());
const hdown = a2.add(a1).addScalar(coeff.getA0());
return hup.div(hdown);
}
function getFreqwList(coeffList: Coeff[], w: number): Complex {
let h = getFreqw(coeffList[0], w);
coeffList.slice(1).forEach((item) => {
h = h.mul(getFreqw(item, w));
});
return h;
}
function getFreqzn(coeff: Coeff, fs: number, f: number[]): number[] {
const n = f.length;
const h = new Array(n);
for (let i = 0; i < n; i++) {
const w = (2 * Math.PI * f[i]) / fs;
const complex = getFreqw(coeff, w);
h[i] = 20 * Math.log10(getAmplitude(complex));
}
return h;
}
function getFreqznList(coeffList: Coeff[], fs: number, f: number[]): number[] {
const n = f.length;
const h = new Array(n);
for (let i = 0; i < n; i++) {
const w = (2 * Math.PI * f[i]) / fs;
const complex = getFreqwList(coeffList, w);
h[i] = 20 * Math.log10(getAmplitude(complex));
}
return h;
}
/**
* Calculate filter coefficients matrix
*/
export function getSectionsMatrix(
gain: number,
fc: number,
q: number,
type: number,
bypass: boolean,
fs: number
): Coeff {
const w = (2 * fc) / fs;
const bypassStatus = bypass ? 1 : 0;
const bqtype = type + bypassStatus * 10;
const sA = Math.pow(10, gain / 40);
const sqrt_sA = Math.sqrt(sA);
const sin_w = Math.sin(Math.PI * w);
const cos_w = Math.cos(Math.PI * w);
const alpha = sin_w / (2 * q);
let b0 = 0, b1 = 0, b2 = 0, a0 = 0, a1 = 0, a2 = 0;
switch (bqtype) {
case TYPE_PEAK:
b0 = 1 + alpha * sA;
b1 = -2 * cos_w;
b2 = 1 - alpha * sA;
a0 = 1 + alpha / sA;
a1 = b1;
a2 = 1 - alpha / sA;
break;
case TYPE_LOWSHELF:
b0 = sA * ((sA + 1) - (sA - 1) * cos_w + 2 * sqrt_sA * alpha);
b1 = 2 * sA * ((sA - 1) - (sA + 1) * cos_w);
b2 = sA * ((sA + 1) - (sA - 1) * cos_w - 2 * sqrt_sA * alpha);
a0 = (sA + 1) + (sA - 1) * cos_w + 2 * sqrt_sA * alpha;
a1 = -2 * ((sA - 1) + (sA + 1) * cos_w);
a2 = (sA + 1) + (sA - 1) * cos_w - 2 * sqrt_sA * alpha;
break;
case TYPE_HIGHSHELF:
b0 = sA * ((sA + 1) + (sA - 1) * cos_w + 2 * sqrt_sA * alpha);
b1 = -2 * sA * ((sA - 1) + (sA + 1) * cos_w);
b2 = sA * ((sA + 1) + (sA - 1) * cos_w - 2 * sqrt_sA * alpha);
a0 = (sA + 1) - (sA - 1) * cos_w + 2 * sqrt_sA * alpha;
a1 = 2 * ((sA - 1) - (sA + 1) * cos_w);
a2 = (sA + 1) - (sA - 1) * cos_w - 2 * sqrt_sA * alpha;
break;
case TYPE_LOWPASS:
b0 = (1 - cos_w) / 2;
b1 = 1 - cos_w;
b2 = b0;
a0 = 1 + alpha;
a1 = -2 * cos_w;
a2 = 1 - alpha;
break;
case TYPE_HIGHPASS:
b0 = (1 + cos_w) / 2;
b1 = -(1 + cos_w);
b2 = b0;
a0 = 1 + alpha;
a1 = -2 * cos_w;
a2 = 1 - alpha;
break;
case TYPE_BANDPASS:
b0 = alpha;
b1 = 0;
b2 = -alpha;
a0 = 1 + alpha;
a1 = -2 * cos_w;
a2 = 1 - alpha;
break;
case TYPE_NOTCH:
b0 = 1;
b1 = -2 * cos_w;
b2 = 1;
a0 = 1 + alpha;
a1 = -2 * cos_w;
a2 = 1 - alpha;
break;
case TYPE_ALLPASS:
b0 = 1 - alpha;
b1 = -2 * cos_w;
b2 = 1 + alpha;
a0 = b2;
a1 = b1;
a2 = b0;
break;
default:
b0 = 1;
b1 = 0;
b2 = 0;
a0 = 1;
a1 = 0;
a2 = 0;
break;
}
const coeff = new Coeff();
coeff.setB0(b0 / a0);
coeff.setB1(b1 / a0);
coeff.setB2(b2 / a0);
coeff.setA0(a0 / a0);
coeff.setA1(a1 / a0);
coeff.setA2(a2 / a0);
return coeff;
}
/**
* Visualize frequency response
* Returns [xValues, yValues] for the overall response
*/
export function visualizeResponse(coeffList: Coeff[], fs: number): [number[], number[]] {
const n = 349;
const startF = 20;
const logStep = (Math.log10(20000) - Math.log10(20)) / n;
const f = new Array(n);
const step = Math.pow(10, logStep);
for (let i = 0; i < n; i++) {
f[i] = startF * Math.pow(step, i);
}
const semilogf = new Array(n);
// Only calculate first 200 points for x-axis
for (let i = 0; i < 200; i++) {
semilogf[i] = Math.log10(f[i]);
}
const validCoeffList: Coeff[] = [];
coeffList.forEach((coeff) => {
if (
!Number.isNaN(coeff.getB0()) &&
!Number.isNaN(coeff.getB1()) &&
!Number.isNaN(coeff.getB2()) &&
!Number.isNaN(coeff.getA0()) &&
!Number.isNaN(coeff.getA1()) &&
!Number.isNaN(coeff.getA2())
) {
validCoeffList.push(coeff);
}
});
if (validCoeffList.length === 0) {
const flat = f.map(() => 0);
return [semilogf, flat];
}
const overall = getFreqznList(validCoeffList, fs, f);
return [semilogf, overall];
}
/** Same log-spaced grid as visualizeResponse (20 Hz … 20 kHz, 349 points). */
export function getPeqLogSpacedFreqs(): number[] {
const n = 349;
const startF = 20;
const logStep = (Math.log10(20000) - Math.log10(20)) / n;
const step = Math.pow(10, logStep);
const f: number[] = [];
for (let i = 0; i < n; i++) {
f.push(startF * Math.pow(step, i));
}
return f;
}
export type PeqBandForResponse = {
enabled: boolean;
gain: number;
freq: number;
q: number;
type: string | number;
};
/**
* Combined magnitude response (dB) per band — same pipeline as legacy:
* getSectionsMatrix(...) per filter, then cascade via getFreqznList.
*/
export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number): number[] {
const f = getPeqLogSpacedFreqs();
const list: Coeff[] = [];
bands.forEach((b) => {
if (!b.enabled) return;
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), false, fs));
});
if (list.length === 0) {
return f.map(() => 0);
}
return getFreqznList(list, fs, f);
}
export type PeqSvgCurveData = {
points: Array<{ x: number; y: number; gainDb: number; freq: number }>;
pathD: string;
fillD: string;
};
/**
* Build reusable SVG curve data (`points`, `pathD`, `fillD`) for PEQ response rendering.
* Any page can call this and render with SVG `<path d={pathD} />`.
*/
export function buildPeqSvgCurveData(args: {
bands: PeqBandForResponse[];
width: number;
height: number;
fs?: number;
yDbMax?: number;
minFreq?: number;
maxFreq?: number;
paddingY?: number;
}): PeqSvgCurveData {
const {
bands,
width,
height,
fs = 48000,
yDbMax = 20,
minFreq = 20,
maxFreq = 20000,
paddingY = 10,
} = args;
const safeW = Math.max(1, width);
const safeH = Math.max(1, height);
const logMin = Math.log10(minFreq);
const logMax = Math.log10(maxFreq);
const freqToX = (f: number) =>
((Math.log10(Math.max(minFreq, Math.min(maxFreq, f))) - logMin) / (logMax - logMin)) * safeW;
const gainToY = (g: number) => safeH / 2 - (g / yDbMax) * (safeH / 2 - paddingY);
const freqs = getPeqLogSpacedFreqs();
const db = computePeqMagnitudeDb(bands, fs);
const points = freqs.map((f, i) => {
const gainDb = db[i] ?? 0;
return { x: freqToX(f), y: gainToY(gainDb), gainDb, freq: f };
});
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
const fillD = pathD ? `${pathD} L ${safeW} ${safeH / 2} L 0 ${safeH / 2} Z` : "";
return { points, pathD, fillD };
}
/**
* Get ECharts options for frequency response chart
*/
export function getChartOps(
dataSet: [number[], number[]],
yMax: number,
yMin: number,
lineColor: string
) {
const labelsToShow = [0, 43, 85, 116, 160, 200, 233, 281, 320, 348];
const labelMap: { [key: number]: string } = {
0: '20',
43: '50',
85: '100',
116: '200',
160: '500',
200: '1K',
233: '2K',
281: '5K',
320: '10K',
348: '20K',
};
return {
xAxis: {
type: 'category' as const,
data: dataSet[0],
axisLabel: {
formatter: (value: any, index: number) => {
if (labelsToShow.includes(index)) {
return labelMap[index] || '';
}
return '';
},
interval: (index: number) => labelsToShow.includes(index),
fontSize: 14,
color: '#999',
},
splitLine: {
show: true,
lineStyle: {
type: 'solid',
color: '#3A4348',
},
},
axisLine: {
lineStyle: {
color: '#3A4348',
},
},
},
yAxis: {
type: 'value' as const,
min: yMin,
max: yMax,
interval: 5,
axisLabel: {
fontSize: 14,
color: '#999',
},
splitLine: {
show: true,
lineStyle: {
type: 'solid',
color: '#3A4348',
},
},
axisLine: {
lineStyle: {
color: '#3A4348',
},
},
},
legend: {
show: true,
top: 15,
data: [
{
name: 'Equalizer',
icon: 'circle',
textStyle: { color: lineColor },
itemStyle: {
color: lineColor,
borderColor: lineColor,
},
},
{
name: 'Raw',
icon: 'circle',
textStyle: { color: '#ffffff' },
itemStyle: {
color: '#ffffff',
borderColor: '#ffffff',
},
},
{
name: 'Equalized',
icon: 'circle',
textStyle: { color: '#23d2fe' },
itemStyle: {
color: '#23d2fe',
borderColor: '#23d2fe',
},
},
],
textStyle: {
fontSize: 13,
},
},
series: [
{
name: 'Equalizer',
data: dataSet[1],
type: 'line' as const,
showSymbol: false,
lineStyle: {
color: lineColor,
},
areaStyle: {
opacity: 0.3,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#578400' },
{ offset: 1, color: '#578400' },
]),
},
},
],
};
}