refactor: graph to use a set of points instead of taking the whole dsp oobject

This commit is contained in:
2026-08-31 20:14:07 +02:00
parent 21728b5e6d
commit 75ac349790
13 changed files with 717 additions and 823 deletions
@@ -103,3 +103,52 @@
flex: 1;
min-height: 0;
}
.gain-graph-title-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1cqh;
padding: 1cqh 1.5cqh;
flex-wrap: wrap;
}
.gain-graph-title-row .gain-graph-title {
padding: 0;
}
.magnitude-selector {
display: flex;
flex-wrap: wrap;
gap: 0.5cqh;
}
.magnitude-selector__chip {
display: flex;
align-items: center;
gap: 0.6cqh;
font-family: var(--font-ui);
font-size: 1.8cqh;
padding: 0.4cqh 1cqh;
background: var(--bg-void);
color: var(--text-muted);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.5;
}
.magnitude-selector__chip.active {
opacity: 1;
color: var(--text-primary);
border-color: var(--chip-color);
}
.magnitude-selector__swatch {
width: 1.2cqh;
height: 1.2cqh;
border-radius: 50%;
background: var(--chip-color);
flex: 0 0 auto;
}
@@ -6,7 +6,36 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import './GainSection.css';
import { DiscreteFrequency } from '../../types';
import { DISCRETE_FREQUENCIES } from '../../constants';
import {
DISCRETE_FREQUENCIES,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
GRAPH_Y_MIN,
INPUT_ORDER,
OUTPUT_ORDER,
} from '../../constants';
import { useState } from 'react';
// import { OverlayChannelSpec, buildInputMagnitudeOverlayDraw } from '../../utils/overlayCurve';
const CHANNEL_COLORS = [
'#4ade80',
'#60a5fa',
'#f472b6',
'#fbbf24',
'#a78bfa',
'#34d399',
'#f87171',
'#38bdf8',
];
function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
const map = {} as Record<T, string>;
channels.forEach((ch, i) => {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
});
return map;
}
function GainPanel({
dsp,
@@ -90,6 +119,40 @@ function GainPanel({
);
}
function ChannelSelector<T extends string>({
channels,
labels,
colors,
selected,
onToggle,
}: {
channels: T[];
labels: Record<T, string>;
colors: Record<T, string>;
selected: Set<T>;
onToggle: (channel: T) => void;
}) {
return (
<div className="magnitude-selector">
{channels.map((channel) => {
const isOn = selected.has(channel);
return (
<button
key={channel}
type="button"
className={`magnitude-selector__chip${isOn ? ' active' : ''}`}
style={{ '--chip-color': colors[channel] } as React.CSSProperties}
onClick={() => onToggle(channel)}
>
<span className="magnitude-selector__swatch" />
{labels[channel]}
</button>
);
})}
</div>
);
}
function GainSection({
dsp,
setGain,
@@ -101,87 +164,81 @@ function GainSection({
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const inputs: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
const currentChannel = dsp.state.current_config.input_states[InputChannel.InA];
const inputColors = buildColorMap(INPUT_ORDER);
const outputColors = buildColorMap(OUTPUT_ORDER);
const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_ORDER));
const [selectedOutputs, setSelectedOutputs] = useState<Set<OutputChannel>>(new Set(OUTPUT_ORDER));
const inputLabels: Record<InputChannel, string> = INPUT_ORDER.reduce(
(acc, channel) => ({
...acc,
[channel]: dsp.state.current_config.input_states[channel].name,
}),
{} as Record<InputChannel, string>
);
const toggleInput = (channel: InputChannel) => {
setSelectedInputs((prev) => {
const next = new Set(prev);
next.has(channel) ? next.delete(channel) : next.add(channel);
return next;
});
};
const toggleOutput = (channel: OutputChannel) => {
setSelectedOutputs((prev) => {
const next = new Set(prev);
next.has(channel) ? next.delete(channel) : next.add(channel);
return next;
});
};
// const inputOverlaySpecs: OverlayChannelSpec<InputChannel>[] = INPUT_ORDER.filter((c) =>
// selectedInputs.has(c)
// ).map((channel) => ({ channel, color: inputColors[channel] }));
// const outputOverlaySpecs: OverlayChannelSpec<OutputChannel>[] = OUTPUT_ORDER.filter((c) =>
// selectedOutputs.has(c)
// ).map((channel) => ({ channel, color: outputColors[channel] }));
return (
<>
<div className="gain-linear-graph-section">
<div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div>
<LinearGraph
xMin={10}
xMax={30000}
<div className="gain-graph-title-row">
<div className="gain-graph-title">Input Magnitude</div>
<ChannelSelector
channels={INPUT_ORDER}
labels={inputLabels}
colors={inputColors}
selected={selectedInputs}
onToggle={toggleInput}
/>
</div>
{/* <LinearGraph
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={-18}
yMax={18}
yMin={GRAPH_Y_MIN}
yMax={GRAPH_Y_MAX}
yStep={3}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
const geq = currentChannel.geq;
if (geq.bypass) {
return;
}
const points = Object.entries(geq.gains)
.map(([frequency, gain]) => ({
frequency: DISCRETE_FREQUENCIES[frequency as DiscreteFrequency],
gain,
}))
.sort((a, b) => a.frequency - b.frequency)
.map(({ frequency, gain }) => {
const [x, y] = toPx(frequency, gain);
return { x, y };
});
if (points.length < 2) {
return;
}
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const previous = points[i - 1];
const current = points[i];
const midX = (previous.x + current.x) / 2;
ctx.quadraticCurveTo(previous.x, previous.y, midX, (previous.y + current.y) / 2);
ctx.quadraticCurveTo(current.x, current.y, current.x, current.y);
}
ctx.stroke();
}}
/>
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={buildInputMagnitudeOverlayDraw({
channels: inputOverlaySpecs,
getGains: (channel) => dsp.state.current_config.input_states[channel].geq.gains,
getBypass: (channel) => dsp.state.current_config.input_states[channel].geq.bypass,
})}
/> */}
</div>
<div className="gain-graph-panel">
{/* <div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
@@ -202,13 +259,13 @@ function GainSection({
ctx.stroke();
}}
/>
</div>
</div> */}
</div>
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
{INPUT_ORDER.map((channel) => (
<GainPanel
key={channel}
dsp={dsp}
@@ -222,12 +279,12 @@ function GainSection({
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
{OUTPUT_ORDER.map((channel) => (
<GainPanel
key={title}
key={channel}
dsp={dsp}
channel={channel}
title={title}
channel={{ Output: channel }}
title={dsp.state.current_config.output_states[channel].name}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
@@ -4,21 +4,23 @@ import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { GEQ_BAND_HZ, buildGeqCurveDraw } from '../../utils/geqCurve';
import { drawCurves } from '../../utils/graph/draw';
import {
INPUT_ORDER,
GRAPH_X_MIN,
GRAPH_X_MAX,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
GRAPH_GEQ_CURVE_Q,
GEQ_GAIN_MIN,
GEQ_GAIN_MAX,
GEQ_GAIN_STEP,
GEQ_BANDS_LABEL,
GEQ_CURVE_Q,
} from '../../constants';
import { useState } from 'react';
import './GeqSection.css';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { logSpace } from '../../utils/graph/curve';
type LiveBands = Partial<Record<DiscreteFrequency, number>>;
@@ -83,6 +85,7 @@ function GeqGraph({
const values = GEQ_BANDS_LABEL.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
);
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return (
<div className="geq-graph-panel">
@@ -97,16 +100,23 @@ function GeqGraph({
xLabel="Band Hz"
yLabel="dB"
showGrid={true}
draw={buildGeqCurveDraw({
values,
bandHz: GEQ_BAND_HZ,
q: GRAPH_GEQ_CURVE_Q * 0.5,
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
dimmed: geq.bypass,
})}
curves: [
{
points: getGeqCurvePoints({ freqs, values, q: GEQ_CURVE_Q }),
style: { strokeColor: '#e5e7eb', dimmed: geq.bypass },
},
],
});
}}
/>
{geq.bypass && (
<div className="geq-bypass-overlay">
@@ -18,7 +18,7 @@ import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button';
import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
// import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
import {
FREQUENCY_MAX,
FREQUENCY_MIN,
@@ -28,6 +28,10 @@ import {
GRAPH_Y_MAX,
GRAPH_Y_MIN,
} from '../../constants';
import { drawCurves } from '../../utils/graph/draw';
import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq';
import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve';
import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover';
type GraphView = 'both' | 'magnitude' | 'phase';
@@ -601,6 +605,8 @@ function InputSection({
const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope };
const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope };
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return (
<>
<div className="input-graph-section">
@@ -625,16 +631,30 @@ function InputSection({
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={buildTotalCurveDraw({
bands: displayPeq,
highPass: highPassSpec,
lowPass: lowPassSpec,
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
dimmed: ActivePeq.bypass,
})}
curves: [
{
points: sumCurvePoints([
getPeqCurvePoints({ freqs, bands: displayPeq }),
getCrossoverCurvePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/>
</div>
)}
@@ -652,16 +672,30 @@ function InputSection({
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={buildTotalPhaseCurveDraw({
bands: displayPeq,
highPass: highPassSpec,
lowPass: lowPassSpec,
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: -180,
yMax: 180,
dimmed: ActivePeq.bypass,
})}
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
curves: [
{
points: sumPhaseCurvePoints([
getPeqPhasePoints({ freqs, bands: displayPeq }),
getCrossoverPhasePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/>
)}
</div>
+1 -1
View File
@@ -61,7 +61,6 @@ export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
};
// ------ GEQ constants ------
export const GRAPH_GEQ_CURVE_Q = 8.65;
export const GEQ_BANDS_LABEL = [
{ frequency: DiscreteFrequency.F20, label: '20' },
@@ -100,6 +99,7 @@ export const GEQ_BANDS_LABEL = [
export const GEQ_GAIN_MIN = -12;
export const GEQ_GAIN_MAX = 12;
export const GEQ_GAIN_STEP = 0.1;
export const GEQ_CURVE_Q = 8.65 * 0.5;
// ------ Filter constants ------
-137
View File
@@ -1,137 +0,0 @@
export const GEQ_BAND_HZ = [
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
] as const;
/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */
export function geqPeakResponseDb(
freq: number,
bandFreq: number,
gainDb: number,
q: number
): number {
if (gainDb === 0) return 0;
const ratio = freq / bandFreq - bandFreq / freq;
return gainDb / (1 + (q * ratio) ** 2);
}
/** Sum of all bands' contributions at a given frequency. */
export function geqTotalCurveDb(
freq: number,
values: number[],
bandHz: readonly number[] = GEQ_BAND_HZ,
q: number
): number {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += geqPeakResponseDb(freq, bandHz[i], values[i], q);
}
return sum;
}
/** Log-spaced sample points from xMin to xMax, for a pixel-smooth curve on a log x-axis. */
export function logSpace(xMin: number, xMax: number, steps: number): number[] {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
const out: number[] = [];
for (let s = 0; s <= steps; s++) {
out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps));
}
return out;
}
export type GeqCurveColors = {
strokeColor: string;
fillAbove: string;
fillAboveFade: string;
fillBelowFade: string;
fillBelow: string;
};
export const DEFAULT_GEQ_CURVE_COLORS: GeqCurveColors = {
strokeColor: '#e5e7eb',
fillAbove: 'rgba(74, 222, 128, 0.35)',
fillAboveFade: 'rgba(74, 222, 128, 0.08)',
fillBelowFade: 'rgba(248, 113, 113, 0.08)',
fillBelow: 'rgba(248, 113, 113, 0.35)',
};
export type GeqCurveOptions = {
values: number[];
bandHz?: readonly number[];
q: number;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
steps?: number;
colors?: GeqCurveColors;
lineWidth?: number;
dimmed?: boolean;
};
export function buildGeqCurveDraw({
values,
bandHz = GEQ_BAND_HZ,
q,
xMin,
xMax,
yMin,
yMax,
steps = 500,
colors = DEFAULT_GEQ_CURVE_COLORS,
lineWidth = 2,
dimmed = false,
}: GeqCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const freqs = logSpace(xMin, xMax, steps);
const points = freqs.map((freq) => {
const gain = geqTotalCurveDb(freq, values, bandHz, q);
return toPx(freq, gain);
});
ctx.save();
if (dimmed) ctx.globalAlpha = 0.25; // NEW: fade everything drawn below
// Fill area under the curve down to 0 dB
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else 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();
// Stroke the curve on top
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
ctx.strokeStyle = colors.strokeColor;
ctx.lineWidth = lineWidth;
if (dimmed) ctx.setLineDash([6, 4]); // dashed = "not actually applied"
ctx.stroke();
ctx.restore();
};
}
@@ -1,4 +1,4 @@
import { CrossoverFilter } from '../types';
import { CrossoverFilter } from '../../types';
import {
lp6ResponseDb,
hp6ResponseDb,
@@ -9,41 +9,52 @@ import {
lp12PhaseDeg,
hp12PhaseDeg,
} from './biquad';
import { CurvePoint } from './curve';
function butterworthStageQs(order: number): number[] {
const qs: number[] = [];
const pairs = Math.floor(order / 2);
for (let k = 1; k <= pairs; k++) {
qs.push(1 / (2 * Math.cos(((2 * k - 1) * Math.PI) / (2 * order))));
}
return qs;
}
function butterworthLpResponseDb(freq: number, f0: number, order: number): number {
if (order <= 0) return 0;
let sum = order % 2 === 1 ? lp6ResponseDb(freq, f0) : 0;
for (const q of butterworthStageQs(order)) sum += lp12ResponseDb(freq, f0, q);
for (const q of butterworthStageQs(order)) {
sum += lp12ResponseDb(freq, f0, q);
}
return sum;
}
function butterworthHpResponseDb(freq: number, f0: number, order: number): number {
if (order <= 0) return 0;
let sum = order % 2 === 1 ? hp6ResponseDb(freq, f0) : 0;
for (const q of butterworthStageQs(order)) sum += hp12ResponseDb(freq, f0, q);
for (const q of butterworthStageQs(order)) {
sum += hp12ResponseDb(freq, f0, q);
}
return sum;
}
function butterworthLpPhaseDeg(freq: number, f0: number, order: number): number {
if (order <= 0) return 0;
let sum = order % 2 === 1 ? lp6PhaseDeg(freq, f0) : 0;
for (const q of butterworthStageQs(order)) sum += lp12PhaseDeg(freq, f0, q);
for (const q of butterworthStageQs(order)) {
sum += lp12PhaseDeg(freq, f0, q);
}
return sum;
}
function butterworthHpPhaseDeg(freq: number, f0: number, order: number): number {
if (order <= 0) return 0;
let sum = order % 2 === 1 ? hp6PhaseDeg(freq, f0) : 0;
for (const q of butterworthStageQs(order)) sum += hp12PhaseDeg(freq, f0, q);
for (const q of butterworthStageQs(order)) {
sum += hp12PhaseDeg(freq, f0, q);
}
return sum;
}
@@ -80,6 +91,7 @@ const CROSSOVER_SLOPE_CONFIG: Record<
};
export type CrossoverKind = 'lp' | 'hp';
export interface CrossoverSpec {
frequency: number;
filter_type: CrossoverFilter;
@@ -91,8 +103,10 @@ export function crossoverMagnitudeDb(
freq: number
): number {
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
if (!cfg) return 0;
const fn =
kind === 'lp'
? cfg.topology === 'butterworth'
@@ -101,6 +115,7 @@ export function crossoverMagnitudeDb(
: cfg.topology === 'butterworth'
? butterworthHpResponseDb
: linkwitzRileyHpResponseDb;
return fn(freq, spec.frequency, cfg.order);
}
@@ -110,8 +125,10 @@ export function crossoverPhaseDeg(
freq: number
): number {
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
if (!cfg) return 0;
const fn =
kind === 'lp'
? cfg.topology === 'butterworth'
@@ -120,6 +137,7 @@ export function crossoverPhaseDeg(
: cfg.topology === 'butterworth'
? butterworthHpPhaseDeg
: linkwitzRileyHpPhaseDeg;
return fn(freq, spec.frequency, cfg.order);
}
@@ -139,51 +157,38 @@ export function crossoverPairPhaseDeg(
return crossoverPhaseDeg('hp', highPass, freq) + crossoverPhaseDeg('lp', lowPass, freq);
}
export type CrossoverCurveOptions = {
highPass?: CrossoverSpec;
lowPass?: CrossoverSpec;
xMin: number;
xMax: number;
steps?: number;
strokeColor?: string;
lineWidth?: number;
dimmed?: boolean;
};
/** HP+LP-only curve — no PEQ. Use if you ever want crossover shown standalone. */
export function buildCrossoverCurveDraw({
/**
* Computes crossover magnitude points over a caller-supplied frequency
* grid. Pass the same `freqs` array you use for GEQ/PEQ to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getCrossoverCurvePoints({
freqs,
highPass,
lowPass,
xMin,
xMax,
steps = 1000,
strokeColor = '#f59e0b',
lineWidth = 2,
dimmed = false,
}: CrossoverCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
ctx.save();
if (dimmed) {
ctx.globalAlpha = 0.4;
ctx.setLineDash([6, 4]);
freqs: number[];
highPass?: CrossoverSpec;
lowPass?: CrossoverSpec;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: crossoverPairMagnitudeDb(highPass, lowPass, freq),
}));
}
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const freq = Math.pow(10, logMin + (i / steps) * (logMax - logMin));
const [xPx, yPx] = toPx(freq, crossoverPairMagnitudeDb(highPass, lowPass, freq));
i === 0 ? ctx.moveTo(xPx, yPx) : ctx.lineTo(xPx, yPx);
}
ctx.strokeStyle = strokeColor;
ctx.lineWidth = lineWidth;
ctx.stroke();
ctx.restore();
};
/** Computes crossover phase points over a caller-supplied frequency grid. */
export function getCrossoverPhasePoints({
freqs,
highPass,
lowPass,
}: {
freqs: number[];
highPass?: CrossoverSpec;
lowPass?: CrossoverSpec;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: crossoverPairPhaseDeg(highPass, lowPass, freq),
}));
}
+100
View File
@@ -0,0 +1,100 @@
export function logSpace(xMin: number, xMax: number, steps: number): number[] {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
const out: number[] = [];
for (let s = 0; s <= steps; s++) {
out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps));
}
return out;
}
export type CurvePoint = { x: number; y: number };
export type CurveColors = {
strokeColor: string;
fillAbove: string;
fillAboveFade: string;
fillBelowFade: string;
fillBelow: string;
};
export const DEFAULT_CURVE_COLORS: CurveColors = {
strokeColor: '#e5e7eb',
fillAbove: 'rgba(74, 222, 128, 0.35)',
fillAboveFade: 'rgba(74, 222, 128, 0.08)',
fillBelowFade: 'rgba(248, 113, 113, 0.08)',
fillBelow: 'rgba(248, 113, 113, 0.35)',
};
export type CurveStyle = {
strokeColor?: string;
fillColors?: CurveColors;
/** Draw the area fill under/around the curve. Defaults to true. */
fill?: boolean;
lineWidth?: number;
dimmed?: boolean;
};
/** A single polyline ready to be handed to `drawCurves`. */
export type Curve = {
points: CurvePoint[];
style?: CurveStyle;
};
export function sumCurvePoints(pointArrays: CurvePoint[][]): CurvePoint[] {
if (pointArrays.length === 0) return [];
const length = pointArrays[0].length;
const result: CurvePoint[] = new Array(length);
for (let i = 0; i < length; i++) {
let y = 0;
for (const points of pointArrays) {
y += points[i]?.y ?? 0;
}
result[i] = { x: pointArrays[0][i].x, y };
}
return result;
}
export function sumPhaseCurvePoints(
pointArrays: CurvePoint[][]
): CurvePoint[] {
if (pointArrays.length === 0) return [];
const length = pointArrays[0].length;
const result: CurvePoint[] = new Array(length);
let previousPhase = 0;
for (let i = 0; i < length; i++) {
let phase = 0;
for (const points of pointArrays) {
phase += points[i]?.y ?? 0;
}
// Keep phase continuous while summing.
while (phase - previousPhase > 180) {
phase -= 360;
}
while (phase - previousPhase < -180) {
phase += 360;
}
// Wrap to [-180, 180].
const wrapped =
((((phase + 180) % 360) + 360) % 360) - 180;
result[i] = {
x: pointArrays[0][i].x,
y: wrapped,
};
previousPhase = phase;
}
return result;
}
+105
View File
@@ -0,0 +1,105 @@
import {
CurvePoint,
CurveStyle,
Curve,
DEFAULT_CURVE_COLORS,
logSpace,
sumCurvePoints,
} from './curve';
export { sumCurvePoints };
type ToPx = (x: number, y: number) => [number, number];
type CanvasCtx = CanvasRenderingContext2D;
const DEFAULT_STYLE: Required<Omit<CurveStyle, never>> = {
strokeColor: DEFAULT_CURVE_COLORS.strokeColor,
fillColors: DEFAULT_CURVE_COLORS,
fill: true,
lineWidth: 2,
dimmed: false,
};
function drawSingleCurve(
ctx: CanvasCtx,
toPx: ToPx,
points: CurvePoint[],
xMin: number,
xMax: number,
yMin: number,
yMax: number,
style: Required<CurveStyle>
) {
if (points.length < 2) return;
ctx.save();
if (style.dimmed) {
ctx.globalAlpha = 0.25;
ctx.setLineDash([6, 4]);
}
if (style.fill) {
ctx.beginPath();
points.forEach(({ x, y }, i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
const [xEnd, zeroY] = toPx(xMax, 0);
const [xStart] = toPx(xMin, 0);
ctx.lineTo(xEnd, zeroY);
ctx.lineTo(xStart, zeroY);
ctx.closePath();
const [, topY] = toPx(xMin, yMax);
const [, bottomY] = toPx(xMin, yMin);
const gradient = ctx.createLinearGradient(0, topY, 0, bottomY);
gradient.addColorStop(0, style.fillColors.fillAbove);
gradient.addColorStop(0.5, style.fillColors.fillAboveFade);
gradient.addColorStop(0.5, style.fillColors.fillBelowFade);
gradient.addColorStop(1, style.fillColors.fillBelow);
ctx.fillStyle = gradient;
ctx.fill();
}
ctx.beginPath();
points.forEach(({ x, y }, i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.strokeStyle = style.strokeColor;
ctx.lineWidth = style.lineWidth;
ctx.stroke();
ctx.restore();
}
export type DrawCurvesParams = {
ctx: CanvasCtx;
toPx: ToPx;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
/** Each entry is drawn as its own independent polyline. To draw a
* combined curve, sum the point arrays with `sumCurvePoints` first
* and pass the result as a single entry. */
curves: Curve[];
};
/** Generic curve drawer — knows nothing about GEQ/PEQ/crossover. */
export function drawCurves({ ctx, toPx, xMin, xMax, yMin, yMax, curves }: DrawCurvesParams) {
for (const curve of curves) {
const style: Required<CurveStyle> = { ...DEFAULT_STYLE, ...curve.style };
drawSingleCurve(ctx, toPx, curve.points, xMin, xMax, yMin, yMax, style);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { CurvePoint } from './curve';
import { DISCRETE_FREQUENCIES } from '../../constants';
/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */
function geqPeakResponseDb(freq: number, bandFreq: number, gainDb: number, q: number): number {
if (gainDb === 0) return 0;
const ratio = freq / bandFreq - bandFreq / freq;
return gainDb / (1 + (q * ratio) ** 2);
}
/** Sum of all bands' contributions at a given frequency. */
function geqTotalCurveDb(
freq: number,
values: number[],
bandHz: readonly number[] = Object.values(DISCRETE_FREQUENCIES),
q: number
): number {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += geqPeakResponseDb(freq, bandHz[i], values[i], q);
}
return sum;
}
/**
* Computes GEQ magnitude points over a caller-supplied frequency grid.
* Pass the same `freqs` array you use for PEQ/crossover to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getGeqCurvePoints({
freqs,
values,
q,
bandHz = Object.values(DISCRETE_FREQUENCIES),
}: {
freqs: number[];
values: number[];
q: number;
bandHz?: readonly number[];
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: geqTotalCurveDb(freq, values, bandHz, q),
}));
}
@@ -1,11 +1,21 @@
import { PEQ, PEQBand, PEQFilter } from '../types';
import { DEFAULT_SAMPLE_RATE } from './biquad';
import { GeqCurveColors, DEFAULT_GEQ_CURVE_COLORS, logSpace } from './geqCurve';
import { biquadMagnitudeDb, biquadPhaseDeg } from './biquad';
import { PEQ, PEQBand, PEQFilter } from '../../types';
import {
DEFAULT_SAMPLE_RATE,
BiquadCoeffs,
biquadMagnitudeDb,
biquadPhaseDeg,
lp6ResponseDb,
hp6ResponseDb,
lp12ResponseDb,
hp12ResponseDb,
lp6PhaseDeg,
hp6PhaseDeg,
lp12PhaseDeg,
hp12PhaseDeg,
} from './biquad';
import { CurvePoint } from './curve';
type BiquadCoeffs = { b0: number; b1: number; b2: number; a0: number; a1: number; a2: number };
// ----------- Gain Response -----------
// ----------- Magnitude Response -----------
/** RBJ Audio EQ Cookbook peaking-EQ biquad, exact magnitude response (dB). */
function peakResponseDb(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
@@ -86,34 +96,6 @@ function highShelfResponseDb(
return biquadMagnitudeDb(freq, fs, coeffs);
}
/** 1-pole lowpass, -6dB/oct. */
function lp6ResponseDb(freq: number, f0: number): number {
const x = freq / f0;
return -10 * Math.log10(1 + x * x);
}
/** 1-pole highpass, -6dB/oct. */
function hp6ResponseDb(freq: number, f0: number): number {
const x = f0 / freq;
return -10 * Math.log10(1 + x * x);
}
/** 2-pole lowpass, -12dB/oct, resonant. */
function lp12ResponseDb(freq: number, f0: number, q: number): number {
const x = freq / f0;
const a = 1 - x * x;
const b = x / q;
return -10 * Math.log10(a * a + b * b);
}
/** 2-pole highpass, -12dB/oct, resonant. */
function hp12ResponseDb(freq: number, f0: number, q: number): number {
const x = freq / f0;
const a = 1 - x * x;
const b = x / q;
return 10 * Math.log10(x ** 4) - 10 * Math.log10(a * a + b * b);
}
/** Magnitude response (dB) of a single PEQ band at a given frequency. */
function peqBandResponseDb(
freq: number,
@@ -125,114 +107,68 @@ function peqBandResponseDb(
switch (band.filter_type) {
case PEQFilter.Peak:
return peakResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.LowShelf:
return lowShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.HighShelf:
return highShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.Lp6Db:
return lp6ResponseDb(freq, band.frequency);
case PEQFilter.Hp6Db:
return hp6ResponseDb(freq, band.frequency);
case PEQFilter.Lp12Db:
return lp12ResponseDb(freq, band.frequency, band.q);
case PEQFilter.Hp12Db:
return hp12ResponseDb(freq, band.frequency, band.q);
case PEQFilter.AllPass1:
case PEQFilter.AllPass2:
// Allpass filters don't affect magnitude, only phase.
return 0;
default:
return 0;
}
}
/** Sum of all bands' contributions at a given frequency. */
export function peqTotalCurveDb(
function peqTotalCurveDb(
freq: number,
bands: Record<PEQBand, PEQ>,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
let sum = 0;
for (const band of Object.values(PEQBand)) {
sum += peqBandResponseDb(freq, bands[band], sampleRate);
}
return sum;
}
export type PeqCurveOptions = {
bands: Record<PEQBand, PEQ>;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
steps?: number;
colors?: GeqCurveColors; // reuse from GEQ module
lineWidth?: number;
dimmed?: boolean;
sampleRate?: number;
};
export function buildPeqCurveDraw({
/**
* Computes PEQ magnitude points over a caller-supplied frequency grid.
* Pass the same `freqs` array you use for GEQ/crossover to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getPeqCurvePoints({
freqs,
bands,
xMin,
xMax,
yMin,
yMax,
steps = 1000,
colors = DEFAULT_GEQ_CURVE_COLORS,
lineWidth = 2,
dimmed = false,
sampleRate = DEFAULT_SAMPLE_RATE,
}: PeqCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const freqs = logSpace(xMin, xMax, steps);
const points = freqs.map((freq) => {
const gain = peqTotalCurveDb(freq, bands, sampleRate);
return toPx(freq, gain);
});
ctx.save();
if (dimmed) ctx.globalAlpha = 0.25;
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else 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) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
ctx.strokeStyle = colors.strokeColor;
ctx.lineWidth = lineWidth;
if (dimmed) ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.restore();
};
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalCurveDb(freq, bands, sampleRate),
}));
}
// ----------- Phase Response -----------
@@ -307,37 +243,12 @@ function highShelfPhaseDeg(
return biquadPhaseDeg(freq, fs, coeffs);
}
function lp6PhaseDeg(freq: number, f0: number): number {
return -Math.atan(freq / f0) * (180 / Math.PI);
}
function hp6PhaseDeg(freq: number, f0: number): number {
return Math.atan(f0 / freq) * (180 / Math.PI) - 90;
}
function lp12PhaseDeg(freq: number, f0: number, q: number): number {
const x = freq / f0;
const re = 1 - x * x;
const im = x / q;
return -Math.atan2(im, re) * (180 / Math.PI);
}
function hp12PhaseDeg(freq: number, f0: number, q: number): number {
const x = freq / f0;
const re = 1 - x * x;
const im = x / q;
return (Math.PI - Math.atan2(im, re)) * (180 / Math.PI);
}
function allPass1PhaseDeg(freq: number, f0: number): number {
return 180 - (2 * Math.atan(freq / f0) * 180) / Math.PI;
}
function allPass2PhaseDeg(freq: number, f0: number, q: number, fs: number): number {
const w0 = (2 * Math.PI * f0) / fs;
// const w = (2 * Math.PI * freq) / fs;
const cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
@@ -394,7 +305,7 @@ function peqBandPhaseDeg(
}
}
export function peqTotalPhaseDeg(
function peqTotalPhaseDeg(
freq: number,
bands: Record<PEQBand, PEQ>,
sampleRate: number = DEFAULT_SAMPLE_RATE
@@ -417,8 +328,13 @@ function unwrapPhaseDeg(phases: number[]): number[] {
let phase = phases[i];
const previous = result[i - 1];
while (phase - previous > 180) phase -= 360;
while (phase - previous < -180) phase += 360;
while (phase - previous > 180) {
phase -= 360;
}
while (phase - previous < -180) {
phase += 360;
}
result.push(phase);
}
@@ -426,142 +342,103 @@ function unwrapPhaseDeg(phases: number[]): number[] {
return result;
}
export type PeqPhaseCurveOptions = {
bands: Record<PEQBand, PEQ>;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
steps?: number;
colors?: GeqCurveColors;
lineWidth?: number;
dimmed?: boolean;
sampleRate?: number;
};
// export function getPeqPhaseSegments({
// freqs,
// bands,
// sampleRate = DEFAULT_SAMPLE_RATE,
// }: {
// freqs: number[];
// bands: Record<PEQBand, PEQ>;
// sampleRate?: number;
// }): CurvePoint[][] {
// if (freqs.length === 0) return [];
export function buildPeqPhaseCurveDraw({
// const rawPhases = freqs.map((freq) => peqTotalPhaseDeg(freq, bands, sampleRate));
// const phases = unwrapPhaseDeg(rawPhases);
// const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180;
// const segments: CurvePoint[][] = [[]];
// const pushPoint = (x: number, y: number) => {
// segments[segments.length - 1].push({ x, y });
// };
// 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;
// // End current segment at ±180°.
// pushPoint(crossingFreq, approach);
// // Start a new segment at the opposite side of the display.
// segments.push([]);
// pushPoint(crossingFreq, depart);
// }
// pushPoint(freq, wrapToDisplay(phase));
// previousFreq = freq;
// previousPhase = phase;
// }
// return segments.filter((segment) => segment.length >= 2);
// }
export function getPeqPhasePoints({
freqs,
bands,
xMin,
xMax,
yMin,
yMax,
steps = 3000,
colors = DEFAULT_GEQ_CURVE_COLORS,
lineWidth = 2,
dimmed = false,
sampleRate = DEFAULT_SAMPLE_RATE,
}: PeqPhaseCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const freqs = logSpace(xMin, xMax, steps);
const rawPhases = freqs.map((freq) => peqTotalPhaseDeg(freq, bands, 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]);
}
// Collect segments as arrays of {freq, display} points, split at
// ±180° boundary crossings — same crossing math as before.
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); // end current segment
segments.push([]); // start new segment
pushPoint(crossingFreq, depart);
}
pushPoint(freq, wrapToDisplay(phase));
previousFreq = freq;
previousPhase = phase;
}
// --- Fill each segment against the 0° line ---
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();
}
// --- Stroke each segment ---
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();
};
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalPhaseDeg(freq, bands, sampleRate),
}));
}
-254
View File
@@ -1,254 +0,0 @@
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();
};
}