refactor: graph to use a set of points instead of taking the whole dsp oobject
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ------
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
+55
-50
@@ -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]);
|
||||
}
|
||||
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();
|
||||
};
|
||||
}: {
|
||||
freqs: number[];
|
||||
highPass?: CrossoverSpec;
|
||||
lowPass?: CrossoverSpec;
|
||||
}): CurvePoint[] {
|
||||
return freqs.map((freq) => ({
|
||||
x: freq,
|
||||
y: crossoverPairMagnitudeDb(highPass, lowPass, freq),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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),
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user