545 lines
13 KiB
TypeScript
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' },
|
|
]),
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|