255 lines
7.0 KiB
TypeScript
255 lines
7.0 KiB
TypeScript
import { PEQ, PEQBand } from '../types';
|
|
import { GeqCurveColors, DEFAULT_GEQ_CURVE_COLORS, logSpace } from './geqCurve';
|
|
import { peqTotalCurveDb, peqTotalPhaseDeg } from './peqCurve';
|
|
import { CrossoverSpec, crossoverPairMagnitudeDb, crossoverPairPhaseDeg } from './crossoverCurve';
|
|
import { DEFAULT_SAMPLE_RATE } from './biquad';
|
|
|
|
export type TotalCurveOptions = {
|
|
bands: Record<PEQBand, PEQ>;
|
|
highPass?: CrossoverSpec;
|
|
lowPass?: CrossoverSpec;
|
|
xMin: number;
|
|
xMax: number;
|
|
yMin: number;
|
|
yMax: number;
|
|
steps?: number;
|
|
colors?: GeqCurveColors;
|
|
lineWidth?: number;
|
|
dimmed?: boolean;
|
|
sampleRate?: number;
|
|
};
|
|
|
|
export type TotalPhaseCurveOptions = {
|
|
bands: Record<PEQBand, PEQ>;
|
|
highPass?: CrossoverSpec;
|
|
lowPass?: CrossoverSpec;
|
|
xMin: number;
|
|
xMax: number;
|
|
yMin: number;
|
|
yMax: number;
|
|
steps?: number;
|
|
colors?: GeqCurveColors;
|
|
lineWidth?: number;
|
|
dimmed?: boolean;
|
|
sampleRate?: number;
|
|
};
|
|
|
|
function totalMagnitudeDb(
|
|
freq: number,
|
|
bands: Record<PEQBand, PEQ>,
|
|
highPass: CrossoverSpec | undefined,
|
|
lowPass: CrossoverSpec | undefined,
|
|
sampleRate: number
|
|
): number {
|
|
return (
|
|
peqTotalCurveDb(freq, bands, sampleRate) + crossoverPairMagnitudeDb(highPass, lowPass, freq)
|
|
);
|
|
}
|
|
|
|
function totalPhaseDeg(
|
|
freq: number,
|
|
bands: Record<PEQBand, PEQ>,
|
|
highPass: CrossoverSpec | undefined,
|
|
lowPass: CrossoverSpec | undefined,
|
|
sampleRate: number
|
|
): number {
|
|
return peqTotalPhaseDeg(freq, bands, sampleRate) + crossoverPairPhaseDeg(highPass, lowPass, freq);
|
|
}
|
|
|
|
function unwrapPhaseDeg(phases: number[]): number[] {
|
|
if (phases.length === 0) return [];
|
|
const result = [phases[0]];
|
|
for (let i = 1; i < phases.length; i++) {
|
|
let phase = phases[i];
|
|
const previous = result[i - 1];
|
|
while (phase - previous > 180) phase -= 360;
|
|
while (phase - previous < -180) phase += 360;
|
|
result.push(phase);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function buildTotalCurveDraw({
|
|
bands,
|
|
highPass,
|
|
lowPass,
|
|
xMin,
|
|
xMax,
|
|
yMin,
|
|
yMax,
|
|
steps = 1000,
|
|
colors = DEFAULT_GEQ_CURVE_COLORS,
|
|
lineWidth = 2,
|
|
dimmed = false,
|
|
sampleRate = DEFAULT_SAMPLE_RATE,
|
|
}: TotalCurveOptions) {
|
|
return ({
|
|
ctx,
|
|
toPx,
|
|
}: {
|
|
ctx: CanvasRenderingContext2D;
|
|
toPx: (x: number, y: number) => [number, number];
|
|
}) => {
|
|
const freqs = logSpace(xMin, xMax, steps);
|
|
const points = freqs.map((freq) =>
|
|
toPx(freq, totalMagnitudeDb(freq, bands, highPass, lowPass, sampleRate))
|
|
);
|
|
|
|
ctx.save();
|
|
if (dimmed) ctx.globalAlpha = 0.25;
|
|
|
|
ctx.beginPath();
|
|
points.forEach(([xPx, yPx], i) => (i === 0 ? ctx.moveTo(xPx, yPx) : ctx.lineTo(xPx, yPx)));
|
|
const [xEndPx, zeroYPx] = toPx(xMax, 0);
|
|
const [xStartPx] = toPx(xMin, 0);
|
|
ctx.lineTo(xEndPx, zeroYPx);
|
|
ctx.lineTo(xStartPx, zeroYPx);
|
|
ctx.closePath();
|
|
|
|
const [, topPx] = toPx(xMin, yMax);
|
|
const [, bottomPx] = toPx(xMin, yMin);
|
|
const gradient = ctx.createLinearGradient(0, topPx, 0, bottomPx);
|
|
gradient.addColorStop(0, colors.fillAbove);
|
|
gradient.addColorStop(0.5, colors.fillAboveFade);
|
|
gradient.addColorStop(0.5, colors.fillBelowFade);
|
|
gradient.addColorStop(1, colors.fillBelow);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
points.forEach(([xPx, yPx], i) => (i === 0 ? ctx.moveTo(xPx, yPx) : ctx.lineTo(xPx, yPx)));
|
|
ctx.strokeStyle = colors.strokeColor;
|
|
ctx.lineWidth = lineWidth;
|
|
if (dimmed) ctx.setLineDash([6, 4]);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
};
|
|
}
|
|
|
|
export function buildTotalPhaseCurveDraw({
|
|
bands,
|
|
highPass,
|
|
lowPass,
|
|
xMin,
|
|
xMax,
|
|
yMin,
|
|
yMax,
|
|
steps = 3000,
|
|
colors = DEFAULT_GEQ_CURVE_COLORS,
|
|
lineWidth = 2,
|
|
dimmed = false,
|
|
sampleRate = DEFAULT_SAMPLE_RATE,
|
|
}: TotalPhaseCurveOptions) {
|
|
return ({
|
|
ctx,
|
|
toPx,
|
|
}: {
|
|
ctx: CanvasRenderingContext2D;
|
|
toPx: (x: number, y: number) => [number, number];
|
|
}) => {
|
|
const freqs = logSpace(xMin, xMax, steps);
|
|
const rawPhases = freqs.map((freq) =>
|
|
totalPhaseDeg(freq, bands, highPass, lowPass, sampleRate)
|
|
);
|
|
const phases = unwrapPhaseDeg(rawPhases);
|
|
|
|
const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180;
|
|
|
|
ctx.save();
|
|
if (dimmed) {
|
|
ctx.globalAlpha = 0.25;
|
|
ctx.setLineDash([6, 4]);
|
|
}
|
|
|
|
let segments: { freq: number; display: number }[][] = [[]];
|
|
const pushPoint = (freq: number, display: number) => {
|
|
segments[segments.length - 1].push({ freq, display });
|
|
};
|
|
|
|
let previousFreq = freqs[0];
|
|
let previousPhase = phases[0];
|
|
pushPoint(previousFreq, wrapToDisplay(previousPhase));
|
|
|
|
for (let i = 1; i < freqs.length; i++) {
|
|
const freq = freqs[i];
|
|
const phase = phases[i];
|
|
const logPrev = Math.log10(previousFreq);
|
|
const logCurr = Math.log10(freq);
|
|
const direction = phase >= previousPhase ? 1 : -1;
|
|
|
|
const boundaries: number[] = [];
|
|
if (direction > 0) {
|
|
let b = Math.floor((previousPhase - 180) / 360) * 360 + 180;
|
|
while (b <= previousPhase) b += 360;
|
|
while (b < phase) {
|
|
boundaries.push(b);
|
|
b += 360;
|
|
}
|
|
} else {
|
|
let b = Math.ceil((previousPhase - 180) / 360) * 360 + 180;
|
|
while (b >= previousPhase) b -= 360;
|
|
while (b > phase) {
|
|
boundaries.push(b);
|
|
b -= 360;
|
|
}
|
|
}
|
|
|
|
for (const boundary of boundaries) {
|
|
const t = (boundary - previousPhase) / (phase - previousPhase);
|
|
const crossingFreq = Math.pow(10, logPrev + t * (logCurr - logPrev));
|
|
const approach = direction > 0 ? 180 : -180;
|
|
const depart = direction > 0 ? -180 : 180;
|
|
|
|
pushPoint(crossingFreq, approach);
|
|
segments.push([]);
|
|
pushPoint(crossingFreq, depart);
|
|
}
|
|
|
|
pushPoint(freq, wrapToDisplay(phase));
|
|
previousFreq = freq;
|
|
previousPhase = phase;
|
|
}
|
|
|
|
for (const seg of segments) {
|
|
if (seg.length < 2) continue;
|
|
ctx.beginPath();
|
|
seg.forEach((p, i) => {
|
|
const [x, y] = toPx(p.freq, p.display);
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
const [xEndPx, zeroYPx] = toPx(seg[seg.length - 1].freq, 0);
|
|
const [xStartPx] = toPx(seg[0].freq, 0);
|
|
ctx.lineTo(xEndPx, zeroYPx);
|
|
ctx.lineTo(xStartPx, zeroYPx);
|
|
ctx.closePath();
|
|
|
|
const [, topPx] = toPx(xMin, yMax);
|
|
const [, bottomPx] = toPx(xMin, yMin);
|
|
const gradient = ctx.createLinearGradient(0, topPx, 0, bottomPx);
|
|
gradient.addColorStop(0, colors.fillAbove);
|
|
gradient.addColorStop(0.5, colors.fillAboveFade);
|
|
gradient.addColorStop(0.5, colors.fillBelowFade);
|
|
gradient.addColorStop(1, colors.fillBelow);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
}
|
|
|
|
for (const seg of segments) {
|
|
if (seg.length < 2) continue;
|
|
ctx.beginPath();
|
|
seg.forEach((p, i) => {
|
|
const [x, y] = toPx(p.freq, p.display);
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.strokeStyle = colors.strokeColor;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
};
|
|
}
|