// ============================================================ // 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 ``. */ 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' }, ]), }, }, ], }; }