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;
|
flex: 1;
|
||||||
min-height: 0;
|
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 { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||||
import './GainSection.css';
|
import './GainSection.css';
|
||||||
import { DiscreteFrequency } from '../../types';
|
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({
|
function GainPanel({
|
||||||
dsp,
|
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({
|
function GainSection({
|
||||||
dsp,
|
dsp,
|
||||||
setGain,
|
setGain,
|
||||||
@@ -101,87 +164,81 @@ function GainSection({
|
|||||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
||||||
setInverse: (channel: Channel, inverted: 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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="gain-linear-graph-section">
|
<div className="gain-linear-graph-section">
|
||||||
<div className="gain-graph-panel">
|
<div className="gain-graph-panel">
|
||||||
<div className="gain-graph-title">Frequency Response</div>
|
<div className="gain-graph-title-row">
|
||||||
<LinearGraph
|
<div className="gain-graph-title">Input Magnitude</div>
|
||||||
xMin={10}
|
<ChannelSelector
|
||||||
xMax={30000}
|
channels={INPUT_ORDER}
|
||||||
|
labels={inputLabels}
|
||||||
|
colors={inputColors}
|
||||||
|
selected={selectedInputs}
|
||||||
|
onToggle={toggleInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* <LinearGraph
|
||||||
|
xMin={GRAPH_X_MIN}
|
||||||
|
xMax={GRAPH_X_MAX}
|
||||||
xScale="log"
|
xScale="log"
|
||||||
yMin={-18}
|
yMin={GRAPH_Y_MIN}
|
||||||
yMax={18}
|
yMax={GRAPH_Y_MAX}
|
||||||
yStep={3}
|
yStep={3}
|
||||||
xLabel="Hz"
|
xLabel="Hz"
|
||||||
yLabel="dB"
|
yLabel="dB"
|
||||||
draw={({ ctx, toPx }) => {
|
showGrid={true}
|
||||||
const geq = currentChannel.geq;
|
formatX={(v) => Math.round(v).toString()}
|
||||||
|
formatY={(v) => Math.round(v).toString()}
|
||||||
if (geq.bypass) {
|
draw={buildInputMagnitudeOverlayDraw({
|
||||||
return;
|
channels: inputOverlaySpecs,
|
||||||
}
|
getGains: (channel) => dsp.state.current_config.input_states[channel].geq.gains,
|
||||||
|
getBypass: (channel) => dsp.state.current_config.input_states[channel].geq.bypass,
|
||||||
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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gain-graph-panel">
|
{/* <div className="gain-graph-panel">
|
||||||
<div className="gain-graph-title">Phase Response</div>
|
<div className="gain-graph-title">Phase Response</div>
|
||||||
<LinearGraph
|
<LinearGraph
|
||||||
xMin={20}
|
xMin={20}
|
||||||
@@ -202,13 +259,13 @@ function GainSection({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gain-section">
|
<div className="gain-section">
|
||||||
<div className="gain-panels">
|
<div className="gain-panels">
|
||||||
<ChannelGroup title="Input">
|
<ChannelGroup title="Input">
|
||||||
{inputs.map((channel) => (
|
{INPUT_ORDER.map((channel) => (
|
||||||
<GainPanel
|
<GainPanel
|
||||||
key={channel}
|
key={channel}
|
||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
@@ -222,12 +279,12 @@ function GainSection({
|
|||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
|
|
||||||
<ChannelGroup title="Output">
|
<ChannelGroup title="Output">
|
||||||
{outputs.map(({ channel, title }) => (
|
{OUTPUT_ORDER.map((channel) => (
|
||||||
<GainPanel
|
<GainPanel
|
||||||
key={title}
|
key={channel}
|
||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
channel={channel}
|
channel={{ Output: channel }}
|
||||||
title={title}
|
title={dsp.state.current_config.output_states[channel].name}
|
||||||
setGain={setGain}
|
setGain={setGain}
|
||||||
setMute={setMute}
|
setMute={setMute}
|
||||||
setInverse={setInverse}
|
setInverse={setInverse}
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ import { DSP408 as DSP } from '../../../../types';
|
|||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||||
import { GEQ_BAND_HZ, buildGeqCurveDraw } from '../../utils/geqCurve';
|
import { drawCurves } from '../../utils/graph/draw';
|
||||||
import {
|
import {
|
||||||
INPUT_ORDER,
|
INPUT_ORDER,
|
||||||
GRAPH_X_MIN,
|
GRAPH_X_MIN,
|
||||||
GRAPH_X_MAX,
|
GRAPH_X_MAX,
|
||||||
GRAPH_Y_MIN,
|
GRAPH_Y_MIN,
|
||||||
GRAPH_Y_MAX,
|
GRAPH_Y_MAX,
|
||||||
GRAPH_GEQ_CURVE_Q,
|
|
||||||
GEQ_GAIN_MIN,
|
GEQ_GAIN_MIN,
|
||||||
GEQ_GAIN_MAX,
|
GEQ_GAIN_MAX,
|
||||||
GEQ_GAIN_STEP,
|
GEQ_GAIN_STEP,
|
||||||
GEQ_BANDS_LABEL,
|
GEQ_BANDS_LABEL,
|
||||||
|
GEQ_CURVE_Q,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import './GeqSection.css';
|
import './GeqSection.css';
|
||||||
|
import { getGeqCurvePoints } from '../../utils/graph/geq';
|
||||||
|
import { logSpace } from '../../utils/graph/curve';
|
||||||
|
|
||||||
type LiveBands = Partial<Record<DiscreteFrequency, number>>;
|
type LiveBands = Partial<Record<DiscreteFrequency, number>>;
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ function GeqGraph({
|
|||||||
const values = GEQ_BANDS_LABEL.map(
|
const values = GEQ_BANDS_LABEL.map(
|
||||||
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
|
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
|
||||||
);
|
);
|
||||||
|
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="geq-graph-panel">
|
<div className="geq-graph-panel">
|
||||||
@@ -97,16 +100,23 @@ function GeqGraph({
|
|||||||
xLabel="Band Hz"
|
xLabel="Band Hz"
|
||||||
yLabel="dB"
|
yLabel="dB"
|
||||||
showGrid={true}
|
showGrid={true}
|
||||||
draw={buildGeqCurveDraw({
|
draw={({ ctx, toPx }) => {
|
||||||
values,
|
drawCurves({
|
||||||
bandHz: GEQ_BAND_HZ,
|
ctx,
|
||||||
q: GRAPH_GEQ_CURVE_Q * 0.5,
|
toPx,
|
||||||
|
|
||||||
xMin: GRAPH_X_MIN,
|
xMin: GRAPH_X_MIN,
|
||||||
xMax: GRAPH_X_MAX,
|
xMax: GRAPH_X_MAX,
|
||||||
yMin: GRAPH_Y_MIN,
|
yMin: GRAPH_Y_MIN,
|
||||||
yMax: GRAPH_Y_MAX,
|
yMax: GRAPH_Y_MAX,
|
||||||
dimmed: geq.bypass,
|
curves: [
|
||||||
})}
|
{
|
||||||
|
points: getGeqCurvePoints({ freqs, values, q: GEQ_CURVE_Q }),
|
||||||
|
style: { strokeColor: '#e5e7eb', dimmed: geq.bypass },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{geq.bypass && (
|
{geq.bypass && (
|
||||||
<div className="geq-bypass-overlay">
|
<div className="geq-bypass-overlay">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useRef, useState } from 'react';
|
|||||||
import './InputSection.css';
|
import './InputSection.css';
|
||||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
// import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
||||||
import {
|
import {
|
||||||
FREQUENCY_MAX,
|
FREQUENCY_MAX,
|
||||||
FREQUENCY_MIN,
|
FREQUENCY_MIN,
|
||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
GRAPH_Y_MAX,
|
GRAPH_Y_MAX,
|
||||||
GRAPH_Y_MIN,
|
GRAPH_Y_MIN,
|
||||||
} from '../../constants';
|
} 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';
|
type GraphView = 'both' | 'magnitude' | 'phase';
|
||||||
|
|
||||||
@@ -601,6 +605,8 @@ function InputSection({
|
|||||||
const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope };
|
const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope };
|
||||||
const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope };
|
const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope };
|
||||||
|
|
||||||
|
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="input-graph-section">
|
<div className="input-graph-section">
|
||||||
@@ -625,16 +631,30 @@ function InputSection({
|
|||||||
showGrid={true}
|
showGrid={true}
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
formatY={(v) => Math.round(v).toString()}
|
formatY={(v) => Math.round(v).toString()}
|
||||||
draw={buildTotalCurveDraw({
|
draw={({ ctx, toPx }) => {
|
||||||
bands: displayPeq,
|
drawCurves({
|
||||||
highPass: highPassSpec,
|
ctx,
|
||||||
lowPass: lowPassSpec,
|
toPx,
|
||||||
|
|
||||||
xMin: GRAPH_X_MIN,
|
xMin: GRAPH_X_MIN,
|
||||||
xMax: GRAPH_X_MAX,
|
xMax: GRAPH_X_MAX,
|
||||||
yMin: GRAPH_Y_MIN,
|
yMin: GRAPH_Y_MIN,
|
||||||
yMax: GRAPH_Y_MAX,
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -652,16 +672,30 @@ function InputSection({
|
|||||||
showGrid={true}
|
showGrid={true}
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
formatY={(v) => Math.round(v).toString()}
|
formatY={(v) => Math.round(v).toString()}
|
||||||
draw={buildTotalPhaseCurveDraw({
|
draw={({ ctx, toPx }) => {
|
||||||
bands: displayPeq,
|
drawCurves({
|
||||||
highPass: highPassSpec,
|
ctx,
|
||||||
lowPass: lowPassSpec,
|
toPx,
|
||||||
|
|
||||||
xMin: GRAPH_X_MIN,
|
xMin: GRAPH_X_MIN,
|
||||||
xMax: GRAPH_X_MAX,
|
xMax: GRAPH_X_MAX,
|
||||||
yMin: -180,
|
yMin: GRAPH_Y_MIN,
|
||||||
yMax: 180,
|
yMax: GRAPH_Y_MAX,
|
||||||
dimmed: ActivePeq.bypass,
|
curves: [
|
||||||
})}
|
{
|
||||||
|
points: sumPhaseCurvePoints([
|
||||||
|
getPeqPhasePoints({ freqs, bands: displayPeq }),
|
||||||
|
getCrossoverPhasePoints({
|
||||||
|
freqs,
|
||||||
|
highPass: highPassSpec,
|
||||||
|
lowPass: lowPassSpec,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ------ GEQ constants ------
|
// ------ GEQ constants ------
|
||||||
export const GRAPH_GEQ_CURVE_Q = 8.65;
|
|
||||||
|
|
||||||
export const GEQ_BANDS_LABEL = [
|
export const GEQ_BANDS_LABEL = [
|
||||||
{ frequency: DiscreteFrequency.F20, label: '20' },
|
{ frequency: DiscreteFrequency.F20, label: '20' },
|
||||||
@@ -100,6 +99,7 @@ export const GEQ_BANDS_LABEL = [
|
|||||||
export const GEQ_GAIN_MIN = -12;
|
export const GEQ_GAIN_MIN = -12;
|
||||||
export const GEQ_GAIN_MAX = 12;
|
export const GEQ_GAIN_MAX = 12;
|
||||||
export const GEQ_GAIN_STEP = 0.1;
|
export const GEQ_GAIN_STEP = 0.1;
|
||||||
|
export const GEQ_CURVE_Q = 8.65 * 0.5;
|
||||||
|
|
||||||
// ------ Filter constants ------
|
// ------ 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 {
|
import {
|
||||||
lp6ResponseDb,
|
lp6ResponseDb,
|
||||||
hp6ResponseDb,
|
hp6ResponseDb,
|
||||||
@@ -9,41 +9,52 @@ import {
|
|||||||
lp12PhaseDeg,
|
lp12PhaseDeg,
|
||||||
hp12PhaseDeg,
|
hp12PhaseDeg,
|
||||||
} from './biquad';
|
} from './biquad';
|
||||||
|
import { CurvePoint } from './curve';
|
||||||
|
|
||||||
function butterworthStageQs(order: number): number[] {
|
function butterworthStageQs(order: number): number[] {
|
||||||
const qs: number[] = [];
|
const qs: number[] = [];
|
||||||
const pairs = Math.floor(order / 2);
|
const pairs = Math.floor(order / 2);
|
||||||
|
|
||||||
for (let k = 1; k <= pairs; k++) {
|
for (let k = 1; k <= pairs; k++) {
|
||||||
qs.push(1 / (2 * Math.cos(((2 * k - 1) * Math.PI) / (2 * order))));
|
qs.push(1 / (2 * Math.cos(((2 * k - 1) * Math.PI) / (2 * order))));
|
||||||
}
|
}
|
||||||
|
|
||||||
return qs;
|
return qs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function butterworthLpResponseDb(freq: number, f0: number, order: number): number {
|
function butterworthLpResponseDb(freq: number, f0: number, order: number): number {
|
||||||
if (order <= 0) return 0;
|
if (order <= 0) return 0;
|
||||||
let sum = order % 2 === 1 ? lp6ResponseDb(freq, f0) : 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;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
function butterworthHpResponseDb(freq: number, f0: number, order: number): number {
|
function butterworthHpResponseDb(freq: number, f0: number, order: number): number {
|
||||||
if (order <= 0) return 0;
|
if (order <= 0) return 0;
|
||||||
let sum = order % 2 === 1 ? hp6ResponseDb(freq, f0) : 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;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
function butterworthLpPhaseDeg(freq: number, f0: number, order: number): number {
|
function butterworthLpPhaseDeg(freq: number, f0: number, order: number): number {
|
||||||
if (order <= 0) return 0;
|
if (order <= 0) return 0;
|
||||||
let sum = order % 2 === 1 ? lp6PhaseDeg(freq, f0) : 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;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
function butterworthHpPhaseDeg(freq: number, f0: number, order: number): number {
|
function butterworthHpPhaseDeg(freq: number, f0: number, order: number): number {
|
||||||
if (order <= 0) return 0;
|
if (order <= 0) return 0;
|
||||||
let sum = order % 2 === 1 ? hp6PhaseDeg(freq, f0) : 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;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +91,7 @@ const CROSSOVER_SLOPE_CONFIG: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CrossoverKind = 'lp' | 'hp';
|
export type CrossoverKind = 'lp' | 'hp';
|
||||||
|
|
||||||
export interface CrossoverSpec {
|
export interface CrossoverSpec {
|
||||||
frequency: number;
|
frequency: number;
|
||||||
filter_type: CrossoverFilter;
|
filter_type: CrossoverFilter;
|
||||||
@@ -91,8 +103,10 @@ export function crossoverMagnitudeDb(
|
|||||||
freq: number
|
freq: number
|
||||||
): number {
|
): number {
|
||||||
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
|
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
|
||||||
|
|
||||||
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
|
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
|
||||||
if (!cfg) return 0;
|
if (!cfg) return 0;
|
||||||
|
|
||||||
const fn =
|
const fn =
|
||||||
kind === 'lp'
|
kind === 'lp'
|
||||||
? cfg.topology === 'butterworth'
|
? cfg.topology === 'butterworth'
|
||||||
@@ -101,6 +115,7 @@ export function crossoverMagnitudeDb(
|
|||||||
: cfg.topology === 'butterworth'
|
: cfg.topology === 'butterworth'
|
||||||
? butterworthHpResponseDb
|
? butterworthHpResponseDb
|
||||||
: linkwitzRileyHpResponseDb;
|
: linkwitzRileyHpResponseDb;
|
||||||
|
|
||||||
return fn(freq, spec.frequency, cfg.order);
|
return fn(freq, spec.frequency, cfg.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +125,10 @@ export function crossoverPhaseDeg(
|
|||||||
freq: number
|
freq: number
|
||||||
): number {
|
): number {
|
||||||
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
|
if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0;
|
||||||
|
|
||||||
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
|
const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type];
|
||||||
if (!cfg) return 0;
|
if (!cfg) return 0;
|
||||||
|
|
||||||
const fn =
|
const fn =
|
||||||
kind === 'lp'
|
kind === 'lp'
|
||||||
? cfg.topology === 'butterworth'
|
? cfg.topology === 'butterworth'
|
||||||
@@ -120,6 +137,7 @@ export function crossoverPhaseDeg(
|
|||||||
: cfg.topology === 'butterworth'
|
: cfg.topology === 'butterworth'
|
||||||
? butterworthHpPhaseDeg
|
? butterworthHpPhaseDeg
|
||||||
: linkwitzRileyHpPhaseDeg;
|
: linkwitzRileyHpPhaseDeg;
|
||||||
|
|
||||||
return fn(freq, spec.frequency, cfg.order);
|
return fn(freq, spec.frequency, cfg.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,51 +157,38 @@ export function crossoverPairPhaseDeg(
|
|||||||
return crossoverPhaseDeg('hp', highPass, freq) + crossoverPhaseDeg('lp', lowPass, freq);
|
return crossoverPhaseDeg('hp', highPass, freq) + crossoverPhaseDeg('lp', lowPass, freq);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CrossoverCurveOptions = {
|
/**
|
||||||
highPass?: CrossoverSpec;
|
* Computes crossover magnitude points over a caller-supplied frequency
|
||||||
lowPass?: CrossoverSpec;
|
* grid. Pass the same `freqs` array you use for GEQ/PEQ to keep every
|
||||||
xMin: number;
|
* curve on a shared x-grid (required for `sumCurvePoints`).
|
||||||
xMax: number;
|
*/
|
||||||
steps?: number;
|
export function getCrossoverCurvePoints({
|
||||||
strokeColor?: string;
|
freqs,
|
||||||
lineWidth?: number;
|
|
||||||
dimmed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** HP+LP-only curve — no PEQ. Use if you ever want crossover shown standalone. */
|
|
||||||
export function buildCrossoverCurveDraw({
|
|
||||||
highPass,
|
highPass,
|
||||||
lowPass,
|
lowPass,
|
||||||
xMin,
|
}: {
|
||||||
xMax,
|
freqs: number[];
|
||||||
steps = 1000,
|
highPass?: CrossoverSpec;
|
||||||
strokeColor = '#f59e0b',
|
lowPass?: CrossoverSpec;
|
||||||
lineWidth = 2,
|
}): CurvePoint[] {
|
||||||
dimmed = false,
|
return freqs.map((freq) => ({
|
||||||
}: CrossoverCurveOptions) {
|
x: freq,
|
||||||
return ({
|
y: crossoverPairMagnitudeDb(highPass, lowPass, freq),
|
||||||
ctx,
|
}));
|
||||||
toPx,
|
}
|
||||||
}: {
|
|
||||||
ctx: CanvasRenderingContext2D;
|
/** Computes crossover phase points over a caller-supplied frequency grid. */
|
||||||
toPx: (x: number, y: number) => [number, number];
|
export function getCrossoverPhasePoints({
|
||||||
}) => {
|
freqs,
|
||||||
const logMin = Math.log10(xMin);
|
highPass,
|
||||||
const logMax = Math.log10(xMax);
|
lowPass,
|
||||||
ctx.save();
|
}: {
|
||||||
if (dimmed) {
|
freqs: number[];
|
||||||
ctx.globalAlpha = 0.4;
|
highPass?: CrossoverSpec;
|
||||||
ctx.setLineDash([6, 4]);
|
lowPass?: CrossoverSpec;
|
||||||
}
|
}): CurvePoint[] {
|
||||||
ctx.beginPath();
|
return freqs.map((freq) => ({
|
||||||
for (let i = 0; i <= steps; i++) {
|
x: freq,
|
||||||
const freq = Math.pow(10, logMin + (i / steps) * (logMax - logMin));
|
y: crossoverPairPhaseDeg(highPass, lowPass, freq),
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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 { PEQ, PEQBand, PEQFilter } from '../../types';
|
||||||
import { DEFAULT_SAMPLE_RATE } from './biquad';
|
import {
|
||||||
import { GeqCurveColors, DEFAULT_GEQ_CURVE_COLORS, logSpace } from './geqCurve';
|
DEFAULT_SAMPLE_RATE,
|
||||||
import { biquadMagnitudeDb, biquadPhaseDeg } from './biquad';
|
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 };
|
// ----------- Magnitude Response -----------
|
||||||
|
|
||||||
// ----------- Gain Response -----------
|
|
||||||
|
|
||||||
/** RBJ Audio EQ Cookbook peaking-EQ biquad, exact magnitude response (dB). */
|
/** RBJ Audio EQ Cookbook peaking-EQ biquad, exact magnitude response (dB). */
|
||||||
function peakResponseDb(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
|
function peakResponseDb(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
|
||||||
@@ -86,34 +96,6 @@ function highShelfResponseDb(
|
|||||||
return biquadMagnitudeDb(freq, fs, coeffs);
|
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. */
|
/** Magnitude response (dB) of a single PEQ band at a given frequency. */
|
||||||
function peqBandResponseDb(
|
function peqBandResponseDb(
|
||||||
freq: number,
|
freq: number,
|
||||||
@@ -125,114 +107,68 @@ function peqBandResponseDb(
|
|||||||
switch (band.filter_type) {
|
switch (band.filter_type) {
|
||||||
case PEQFilter.Peak:
|
case PEQFilter.Peak:
|
||||||
return peakResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
return peakResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
case PEQFilter.LowShelf:
|
case PEQFilter.LowShelf:
|
||||||
return lowShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
return lowShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
case PEQFilter.HighShelf:
|
case PEQFilter.HighShelf:
|
||||||
return highShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
return highShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
case PEQFilter.Lp6Db:
|
case PEQFilter.Lp6Db:
|
||||||
return lp6ResponseDb(freq, band.frequency);
|
return lp6ResponseDb(freq, band.frequency);
|
||||||
|
|
||||||
case PEQFilter.Hp6Db:
|
case PEQFilter.Hp6Db:
|
||||||
return hp6ResponseDb(freq, band.frequency);
|
return hp6ResponseDb(freq, band.frequency);
|
||||||
|
|
||||||
case PEQFilter.Lp12Db:
|
case PEQFilter.Lp12Db:
|
||||||
return lp12ResponseDb(freq, band.frequency, band.q);
|
return lp12ResponseDb(freq, band.frequency, band.q);
|
||||||
|
|
||||||
case PEQFilter.Hp12Db:
|
case PEQFilter.Hp12Db:
|
||||||
return hp12ResponseDb(freq, band.frequency, band.q);
|
return hp12ResponseDb(freq, band.frequency, band.q);
|
||||||
|
|
||||||
case PEQFilter.AllPass1:
|
case PEQFilter.AllPass1:
|
||||||
case PEQFilter.AllPass2:
|
case PEQFilter.AllPass2:
|
||||||
// Allpass filters don't affect magnitude, only phase.
|
// Allpass filters don't affect magnitude, only phase.
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of all bands' contributions at a given frequency. */
|
/** Sum of all bands' contributions at a given frequency. */
|
||||||
export function peqTotalCurveDb(
|
function peqTotalCurveDb(
|
||||||
freq: number,
|
freq: number,
|
||||||
bands: Record<PEQBand, PEQ>,
|
bands: Record<PEQBand, PEQ>,
|
||||||
sampleRate: number = DEFAULT_SAMPLE_RATE
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
): number {
|
): number {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|
||||||
for (const band of Object.values(PEQBand)) {
|
for (const band of Object.values(PEQBand)) {
|
||||||
sum += peqBandResponseDb(freq, bands[band], sampleRate);
|
sum += peqBandResponseDb(freq, bands[band], sampleRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeqCurveOptions = {
|
/**
|
||||||
bands: Record<PEQBand, PEQ>;
|
* Computes PEQ magnitude points over a caller-supplied frequency grid.
|
||||||
xMin: number;
|
* Pass the same `freqs` array you use for GEQ/crossover to keep every
|
||||||
xMax: number;
|
* curve on a shared x-grid (required for `sumCurvePoints`).
|
||||||
yMin: number;
|
*/
|
||||||
yMax: number;
|
export function getPeqCurvePoints({
|
||||||
steps?: number;
|
freqs,
|
||||||
colors?: GeqCurveColors; // reuse from GEQ module
|
|
||||||
lineWidth?: number;
|
|
||||||
dimmed?: boolean;
|
|
||||||
sampleRate?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildPeqCurveDraw({
|
|
||||||
bands,
|
bands,
|
||||||
xMin,
|
|
||||||
xMax,
|
|
||||||
yMin,
|
|
||||||
yMax,
|
|
||||||
steps = 1000,
|
|
||||||
colors = DEFAULT_GEQ_CURVE_COLORS,
|
|
||||||
lineWidth = 2,
|
|
||||||
dimmed = false,
|
|
||||||
sampleRate = DEFAULT_SAMPLE_RATE,
|
sampleRate = DEFAULT_SAMPLE_RATE,
|
||||||
}: PeqCurveOptions) {
|
}: {
|
||||||
return ({
|
freqs: number[];
|
||||||
ctx,
|
bands: Record<PEQBand, PEQ>;
|
||||||
toPx,
|
sampleRate?: number;
|
||||||
}: {
|
}): CurvePoint[] {
|
||||||
ctx: CanvasRenderingContext2D;
|
return freqs.map((freq) => ({
|
||||||
toPx: (x: number, y: number) => [number, number];
|
x: freq,
|
||||||
}) => {
|
y: peqTotalCurveDb(freq, bands, sampleRate),
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------- Phase Response -----------
|
// ----------- Phase Response -----------
|
||||||
@@ -307,37 +243,12 @@ function highShelfPhaseDeg(
|
|||||||
return biquadPhaseDeg(freq, fs, coeffs);
|
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 {
|
function allPass1PhaseDeg(freq: number, f0: number): number {
|
||||||
return 180 - (2 * Math.atan(freq / f0) * 180) / Math.PI;
|
return 180 - (2 * Math.atan(freq / f0) * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
function allPass2PhaseDeg(freq: number, f0: number, q: number, fs: number): number {
|
function allPass2PhaseDeg(freq: number, f0: number, q: number, fs: number): number {
|
||||||
const w0 = (2 * Math.PI * f0) / fs;
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
// const w = (2 * Math.PI * freq) / fs;
|
|
||||||
|
|
||||||
const cosw0 = Math.cos(w0);
|
const cosw0 = Math.cos(w0);
|
||||||
const alpha = Math.sin(w0) / (2 * q);
|
const alpha = Math.sin(w0) / (2 * q);
|
||||||
@@ -394,7 +305,7 @@ function peqBandPhaseDeg(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function peqTotalPhaseDeg(
|
function peqTotalPhaseDeg(
|
||||||
freq: number,
|
freq: number,
|
||||||
bands: Record<PEQBand, PEQ>,
|
bands: Record<PEQBand, PEQ>,
|
||||||
sampleRate: number = DEFAULT_SAMPLE_RATE
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
@@ -417,8 +328,13 @@ function unwrapPhaseDeg(phases: number[]): number[] {
|
|||||||
let phase = phases[i];
|
let phase = phases[i];
|
||||||
const previous = result[i - 1];
|
const previous = result[i - 1];
|
||||||
|
|
||||||
while (phase - previous > 180) phase -= 360;
|
while (phase - previous > 180) {
|
||||||
while (phase - previous < -180) phase += 360;
|
phase -= 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (phase - previous < -180) {
|
||||||
|
phase += 360;
|
||||||
|
}
|
||||||
|
|
||||||
result.push(phase);
|
result.push(phase);
|
||||||
}
|
}
|
||||||
@@ -426,142 +342,103 @@ function unwrapPhaseDeg(phases: number[]): number[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeqPhaseCurveOptions = {
|
// export function getPeqPhaseSegments({
|
||||||
bands: Record<PEQBand, PEQ>;
|
// freqs,
|
||||||
xMin: number;
|
// bands,
|
||||||
xMax: number;
|
// sampleRate = DEFAULT_SAMPLE_RATE,
|
||||||
yMin: number;
|
// }: {
|
||||||
yMax: number;
|
// freqs: number[];
|
||||||
steps?: number;
|
// bands: Record<PEQBand, PEQ>;
|
||||||
colors?: GeqCurveColors;
|
// sampleRate?: number;
|
||||||
lineWidth?: number;
|
// }): CurvePoint[][] {
|
||||||
dimmed?: boolean;
|
// if (freqs.length === 0) return [];
|
||||||
sampleRate?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
bands,
|
||||||
xMin,
|
|
||||||
xMax,
|
|
||||||
yMin,
|
|
||||||
yMax,
|
|
||||||
steps = 3000,
|
|
||||||
colors = DEFAULT_GEQ_CURVE_COLORS,
|
|
||||||
lineWidth = 2,
|
|
||||||
dimmed = false,
|
|
||||||
sampleRate = DEFAULT_SAMPLE_RATE,
|
sampleRate = DEFAULT_SAMPLE_RATE,
|
||||||
}: PeqPhaseCurveOptions) {
|
}: {
|
||||||
return ({
|
freqs: number[];
|
||||||
ctx,
|
bands: Record<PEQBand, PEQ>;
|
||||||
toPx,
|
sampleRate?: number;
|
||||||
}: {
|
}): CurvePoint[] {
|
||||||
ctx: CanvasRenderingContext2D;
|
return freqs.map((freq) => ({
|
||||||
toPx: (x: number, y: number) => [number, number];
|
x: freq,
|
||||||
}) => {
|
y: peqTotalPhaseDeg(freq, bands, sampleRate),
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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