feat: add peq foor input
This commit is contained in:
@@ -10,17 +10,19 @@ type VerticalSliderProps = {
|
|||||||
max: number;
|
max: number;
|
||||||
|
|
||||||
step?: StepFn;
|
step?: StepFn;
|
||||||
|
quantize?: QuantizeFn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional hard snap function applied after every value change (drag,
|
* Optional discrete value set (must be sorted ascending). When provided,
|
||||||
* keyboard, wheel, or typed input) — takes precedence over the built-in
|
* this takes full precedence over `step`/`quantize`/`scale`: the slider
|
||||||
* step-based rounding. Use this when the underlying hardware/DSP only
|
* only ever lands on entries from this array. `min`/`max` are used to
|
||||||
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw
|
* slice out the applicable sub-range of `values` (e.g. per-filter-type
|
||||||
* index mapped through a log curve) and the slider must always land on
|
* Q ranges that all share one master Q table) — both bounds should be
|
||||||
* one of those exact values rather than an interpolated approximation.
|
* exact members of `values`. Thumb position and drag/keyboard/wheel
|
||||||
* See makeLogQuantize().
|
* navigation are index-based rather than value-based, since spacing in
|
||||||
|
* the array need not be uniform or log-uniform.
|
||||||
*/
|
*/
|
||||||
quantize?: QuantizeFn;
|
values?: readonly number[];
|
||||||
|
|
||||||
value: number;
|
value: number;
|
||||||
onChange?: (value: number) => Promise<boolean>;
|
onChange?: (value: number) => Promise<boolean>;
|
||||||
@@ -31,7 +33,7 @@ type VerticalSliderProps = {
|
|||||||
|
|
||||||
showTicks?: boolean;
|
showTicks?: boolean;
|
||||||
|
|
||||||
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */
|
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
|
||||||
scale?: Scale;
|
scale?: Scale;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -60,8 +62,36 @@ function safeLogBound(n: number) {
|
|||||||
return n > 0 ? n : Number.EPSILON;
|
return n > 0 ? n : Number.EPSILON;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps a real value to a 0..1 position, honoring the chosen scale. */
|
/** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
|
||||||
function valueToPercent(v: number, min: number, max: number, scale: Scale) {
|
function closestIndex(arr: readonly number[], v: number) {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = arr.length - 1;
|
||||||
|
if (v <= arr[0]) return 0;
|
||||||
|
if (v >= arr[hi]) return hi;
|
||||||
|
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid] === v) return mid;
|
||||||
|
if (arr[mid] < v) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = arr[lo - 1];
|
||||||
|
const b = arr[lo];
|
||||||
|
return v - a <= b - v ? lo - 1 : lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */
|
||||||
|
function valueToPercent(
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
scale: Scale,
|
||||||
|
list: readonly number[] | null
|
||||||
|
) {
|
||||||
|
if (list && list.length > 1) {
|
||||||
|
return closestIndex(list, v) / (list.length - 1);
|
||||||
|
}
|
||||||
if (scale === 'log') {
|
if (scale === 'log') {
|
||||||
const logMin = Math.log(safeLogBound(min));
|
const logMin = Math.log(safeLogBound(min));
|
||||||
const logMax = Math.log(safeLogBound(max));
|
const logMax = Math.log(safeLogBound(max));
|
||||||
@@ -73,8 +103,18 @@ function valueToPercent(v: number, min: number, max: number, scale: Scale) {
|
|||||||
return (v - min) / (max - min);
|
return (v - min) / (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */
|
/** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */
|
||||||
function percentToValue(percent: number, min: number, max: number, scale: Scale) {
|
function percentToValue(
|
||||||
|
percent: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
scale: Scale,
|
||||||
|
list: readonly number[] | null
|
||||||
|
) {
|
||||||
|
if (list && list.length > 1) {
|
||||||
|
const idx = Math.round(percent * (list.length - 1));
|
||||||
|
return list[Math.min(list.length - 1, Math.max(0, idx))];
|
||||||
|
}
|
||||||
if (scale === 'log') {
|
if (scale === 'log') {
|
||||||
const logMin = Math.log(safeLogBound(min));
|
const logMin = Math.log(safeLogBound(min));
|
||||||
const logMax = Math.log(safeLogBound(max));
|
const logMax = Math.log(safeLogBound(max));
|
||||||
@@ -126,6 +166,7 @@ export default function VerticalSlider({
|
|||||||
max,
|
max,
|
||||||
step = defaultStep,
|
step = defaultStep,
|
||||||
quantize,
|
quantize,
|
||||||
|
values,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
@@ -151,13 +192,21 @@ export default function VerticalSlider({
|
|||||||
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [tickCount, setTickCount] = useState(MIN_TICKS);
|
const [tickCount, setTickCount] = useState(MIN_TICKS);
|
||||||
|
|
||||||
if (scale === 'log' && (min <= 0 || max <= 0)) {
|
if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn(
|
console.warn(
|
||||||
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
|
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice the discrete value set down to the applicable [min, max] sub-range.
|
||||||
|
// Falls back to the full array if the slice is empty (e.g. misconfigured bounds).
|
||||||
|
const listValues = useMemo(() => {
|
||||||
|
if (!values || values.length === 0) return null;
|
||||||
|
const filtered = values.filter((v) => v >= min && v <= max);
|
||||||
|
return filtered.length > 0 ? filtered : values;
|
||||||
|
}, [values, min, max]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
dragging.current ||
|
dragging.current ||
|
||||||
@@ -191,10 +240,10 @@ export default function VerticalSlider({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const decimals = useMemo(
|
const decimals = useMemo(() => {
|
||||||
() => Math.min(3, getDecimals(step(displayValue))),
|
if (listValues) return Math.min(3, getDecimals(displayValue));
|
||||||
[step, displayValue]
|
return Math.min(3, getDecimals(step(displayValue)));
|
||||||
);
|
}, [listValues, step, displayValue]);
|
||||||
|
|
||||||
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
|
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
|
||||||
|
|
||||||
@@ -203,6 +252,10 @@ export default function VerticalSlider({
|
|||||||
function normalizeValue(input: number) {
|
function normalizeValue(input: number) {
|
||||||
const clamped = Math.min(max, Math.max(min, input));
|
const clamped = Math.min(max, Math.max(min, input));
|
||||||
|
|
||||||
|
if (listValues) {
|
||||||
|
return listValues[closestIndex(listValues, clamped)];
|
||||||
|
}
|
||||||
|
|
||||||
if (quantize) {
|
if (quantize) {
|
||||||
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
|
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
|
||||||
}
|
}
|
||||||
@@ -232,13 +285,23 @@ export default function VerticalSlider({
|
|||||||
// hasn't landed yet, `value` can still be stale, which made repeated
|
// hasn't landed yet, `value` can still be stale, which made repeated
|
||||||
// key presses appear to snap back to the pre-press position.
|
// key presses appear to snap back to the pre-press position.
|
||||||
const base = keyboardValue.current ?? displayValue;
|
const base = keyboardValue.current ?? displayValue;
|
||||||
const currentStep = step(base);
|
|
||||||
|
|
||||||
let next = base;
|
let next: number;
|
||||||
|
if (listValues) {
|
||||||
|
const idx = closestIndex(listValues, base);
|
||||||
|
let nextIdx = idx;
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowRight')
|
||||||
|
nextIdx = Math.min(listValues.length - 1, idx + 1);
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1);
|
||||||
|
next = listValues[nextIdx];
|
||||||
|
} else {
|
||||||
|
const currentStep = step(base);
|
||||||
|
next = base;
|
||||||
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
|
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
|
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
|
||||||
|
|
||||||
next = normalizeValue(next);
|
next = normalizeValue(next);
|
||||||
|
}
|
||||||
|
|
||||||
keyboardValue.current = next;
|
keyboardValue.current = next;
|
||||||
updateValue(next);
|
updateValue(next);
|
||||||
}
|
}
|
||||||
@@ -265,12 +328,20 @@ export default function VerticalSlider({
|
|||||||
wheelEditing.current = true;
|
wheelEditing.current = true;
|
||||||
|
|
||||||
const base = wheelValue.current ?? displayValue;
|
const base = wheelValue.current ?? displayValue;
|
||||||
const currentStep = step(base);
|
|
||||||
// deltaY < 0 means scrolling up/away from the user — treat that as
|
// deltaY < 0 means scrolling up/away from the user — treat that as
|
||||||
// increasing the value, matching the vertical slider's "up = more".
|
// increasing the value, matching the vertical slider's "up = more".
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
|
||||||
const next = normalizeValue(base + direction * currentStep);
|
let next: number;
|
||||||
|
if (listValues) {
|
||||||
|
const idx = closestIndex(listValues, base);
|
||||||
|
const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction));
|
||||||
|
next = listValues[nextIdx];
|
||||||
|
} else {
|
||||||
|
const currentStep = step(base);
|
||||||
|
next = normalizeValue(base + direction * currentStep);
|
||||||
|
}
|
||||||
|
|
||||||
wheelValue.current = next;
|
wheelValue.current = next;
|
||||||
updateValue(next);
|
updateValue(next);
|
||||||
|
|
||||||
@@ -317,7 +388,7 @@ export default function VerticalSlider({
|
|||||||
if (!sliderRef.current) return;
|
if (!sliderRef.current) return;
|
||||||
const rect = sliderRef.current.getBoundingClientRect();
|
const rect = sliderRef.current.getBoundingClientRect();
|
||||||
const percent = 1 - (clientY - rect.top) / rect.height;
|
const percent = 1 - (clientY - rect.top) / rect.height;
|
||||||
const raw = percentToValue(percent, min, max, scale);
|
const raw = percentToValue(percent, min, max, scale, listValues);
|
||||||
updateValue(raw);
|
updateValue(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +449,7 @@ export default function VerticalSlider({
|
|||||||
setEditingText(null);
|
setEditingText(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const percent = valueToPercent(displayValue, min, max, scale);
|
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
||||||
|
|||||||
@@ -146,6 +146,42 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'inb':
|
||||||
|
return (
|
||||||
|
<InputSection
|
||||||
|
dsp={dsp}
|
||||||
|
channel={InputChannel.InB}
|
||||||
|
setLowPass={api.setLowPass}
|
||||||
|
setHighPass={api.setHighPass}
|
||||||
|
setPeq={api.setPeqBand}
|
||||||
|
setPeqBypass={api.setPEQBypass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'inc':
|
||||||
|
return (
|
||||||
|
<InputSection
|
||||||
|
dsp={dsp}
|
||||||
|
channel={InputChannel.InC}
|
||||||
|
setLowPass={api.setLowPass}
|
||||||
|
setHighPass={api.setHighPass}
|
||||||
|
setPeq={api.setPeqBand}
|
||||||
|
setPeqBypass={api.setPEQBypass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'ind':
|
||||||
|
return (
|
||||||
|
<InputSection
|
||||||
|
dsp={dsp}
|
||||||
|
channel={InputChannel.InD}
|
||||||
|
setLowPass={api.setLowPass}
|
||||||
|
setHighPass={api.setHighPass}
|
||||||
|
setPeq={api.setPeqBand}
|
||||||
|
setPeqBypass={api.setPEQBypass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'matrix':
|
case 'matrix':
|
||||||
return (
|
return (
|
||||||
<MatrixSection
|
<MatrixSection
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
|||||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import './DelaySection.css';
|
import './DelaySection.css';
|
||||||
import { DELAY_RANGES } from '../../types';
|
import { DELAY_RANGES } from '../../constants';
|
||||||
|
|
||||||
const UNIT_LABEL: Record<DelayUnit, string> = {
|
const UNIT_LABEL: Record<DelayUnit, string> = {
|
||||||
[DelayUnit.Millisecond]: 'ms',
|
[DelayUnit.Millisecond]: 'ms',
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 10;
|
height: 10;
|
||||||
|
|
||||||
aspect-ratio: 0.75;
|
aspect-ratio: 0.8;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -361,3 +361,47 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-graph-panel__magnitude-wrap {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-graph__title-row {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1cqh 1.5cqh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-graph__title-row .input-graph__title {
|
||||||
|
padding: 0; /* was on the standalone title element; row now owns spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-graph-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4cqh;
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.3cqh;
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-graph-view-toggle__btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 2cqh;
|
||||||
|
padding: 0.6cqh 1.2cqh;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-graph-view-toggle__btn.active {
|
||||||
|
background: var(--accent, #4dd0e1);
|
||||||
|
color: var(--bg-void);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ import VerticalSlider, {
|
|||||||
makeLogStep,
|
makeLogStep,
|
||||||
makeLogQuantize,
|
makeLogQuantize,
|
||||||
} from '../../../../components/VerticalSlider/VerticalSlider';
|
} from '../../../../components/VerticalSlider/VerticalSlider';
|
||||||
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
|
import {
|
||||||
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
|
CROSSOVER_FILTER_LABEL,
|
||||||
// if the actual enum differs.
|
PEQ_BANDS_ORDER,
|
||||||
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter, Crossover } from '../../types';
|
PEQ_FILTERS_LABEL,
|
||||||
|
PEQ_Q_RANGES,
|
||||||
|
PEAK_Q_VALUES,
|
||||||
|
} from '../../constants';
|
||||||
|
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
|
||||||
import { DSP408 as DSP } from '../../../../types';
|
import { DSP408 as DSP } from '../../../../types';
|
||||||
import { CrossoverFilter } from '../../types';
|
import { CrossoverFilter } from '../../types';
|
||||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
|
||||||
import { useRef, useState } from 'react';
|
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 MeterBar from '../../../../components/MeterBar/MeterBar';
|
import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
||||||
import {
|
import {
|
||||||
FREQUENCY_MAX,
|
FREQUENCY_MAX,
|
||||||
FREQUENCY_MIN,
|
FREQUENCY_MIN,
|
||||||
@@ -25,114 +29,161 @@ import {
|
|||||||
GRAPH_Y_MIN,
|
GRAPH_Y_MIN,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
const PEQ_BANDS: PEQBand[] = [
|
type GraphView = 'both' | 'magnitude' | 'phase';
|
||||||
PEQBand.B1,
|
|
||||||
PEQBand.B2,
|
function GraphViewToggle({
|
||||||
PEQBand.B3,
|
view,
|
||||||
PEQBand.B4,
|
onChange,
|
||||||
PEQBand.B5,
|
}: {
|
||||||
PEQBand.B6,
|
view: GraphView;
|
||||||
PEQBand.B7,
|
onChange: (v: GraphView) => void;
|
||||||
PEQBand.B8,
|
}) {
|
||||||
|
const options: { value: GraphView; label: string }[] = [
|
||||||
|
{ value: 'both', label: 'Both' },
|
||||||
|
{ value: 'magnitude', label: 'Mag' },
|
||||||
|
{ value: 'phase', label: 'Phase' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types
|
return (
|
||||||
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [
|
<div className="input-graph-view-toggle">
|
||||||
{ value: PEQFilter.Peak, label: 'Peak' },
|
{options.map((opt) => (
|
||||||
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
|
<button
|
||||||
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
|
key={opt.value}
|
||||||
];
|
type="button"
|
||||||
|
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
|
function InputActions({
|
||||||
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
|
bypass,
|
||||||
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
|
resetting,
|
||||||
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
|
onBypass,
|
||||||
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
|
onReset,
|
||||||
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
|
}: {
|
||||||
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
|
bypass: boolean;
|
||||||
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
|
resetting: boolean;
|
||||||
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
|
onBypass: () => void;
|
||||||
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
|
onReset: () => void;
|
||||||
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
|
}) {
|
||||||
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
|
return (
|
||||||
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
|
<div className="geq-channel-selector">
|
||||||
];
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className={bypass ? 'active-warning' : ''}
|
||||||
|
onClick={onBypass}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '3.5cqh',
|
||||||
|
paddingTop: '2cqh',
|
||||||
|
paddingBottom: '2cqh',
|
||||||
|
minHeight: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bypass ? 'Bypassed' : 'Bypass'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
const PEQ_SLIDER_PARAMS = [
|
<Button
|
||||||
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
|
variant="toggle"
|
||||||
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
|
onClick={onReset}
|
||||||
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
|
disabled={resetting}
|
||||||
] as const;
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '3.5cqh',
|
||||||
|
paddingTop: '2cqh',
|
||||||
|
paddingBottom: '2cqh',
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resetting ? 'Resetting…' : 'Reset'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SetPEQFn = (
|
function PeqPanelSliders({
|
||||||
channel: Channel,
|
peq,
|
||||||
band: PEQBand,
|
setPEQ,
|
||||||
|
onLiveValue,
|
||||||
|
}: {
|
||||||
|
peq: PEQ;
|
||||||
|
setPEQ: (
|
||||||
gainDb: number,
|
gainDb: number,
|
||||||
freqHz: number,
|
freqHz: number,
|
||||||
q: number,
|
q: number,
|
||||||
filterType: PEQFilter,
|
filterType: PEQFilter,
|
||||||
bypass: boolean
|
bypass: boolean
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
onLiveValue: (value: PEQ) => void;
|
||||||
type PeqParamKey = 'frequency' | 'q' | 'gain';
|
|
||||||
|
|
||||||
type PEQValue = {
|
|
||||||
frequency: number;
|
|
||||||
q: number;
|
|
||||||
gain: number;
|
|
||||||
filter_type: PEQFilter;
|
|
||||||
bypass: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PeqPanelSliders({
|
|
||||||
channel,
|
|
||||||
dsp,
|
|
||||||
band,
|
|
||||||
setPEQ,
|
|
||||||
// onLiveValue,
|
|
||||||
}: {
|
|
||||||
channel: InputChannel;
|
|
||||||
dsp: DSP;
|
|
||||||
band: PEQBand;
|
|
||||||
setPEQ: SetPEQFn;
|
|
||||||
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const inputState = dsp.state.current_config.input_states[channel];
|
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
|
||||||
|
|
||||||
const commit = (overrides: Partial<PEQValue>) => {
|
const commit = (overrides: Partial<PEQ>) => {
|
||||||
const next = { ...peq, ...overrides };
|
const next = { ...peq, ...overrides };
|
||||||
return setPEQ(
|
return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||||
{ Input: channel },
|
|
||||||
band,
|
|
||||||
next.gain,
|
|
||||||
next.frequency,
|
|
||||||
next.q,
|
|
||||||
next.filter_type,
|
|
||||||
next.bypass
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="input-panel">
|
<div className="input-panel">
|
||||||
<div className="input-panel__title">{'PEQ Parameters'}</div>
|
<div className="input-panel__title">PEQ Parameters</div>
|
||||||
|
|
||||||
<div className="input-panel__grid">
|
<div className="input-panel__grid">
|
||||||
{PEQ_SLIDER_PARAMS.map((param) => (
|
<div className="input-panel__cell input-panel__cell--frequency">
|
||||||
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
|
|
||||||
<VerticalSlider
|
<VerticalSlider
|
||||||
min={param.min}
|
min={FREQUENCY_MIN}
|
||||||
max={param.max}
|
max={FREQUENCY_MAX}
|
||||||
step={() => param.step}
|
scale="log"
|
||||||
unit={param.unit}
|
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
|
||||||
value={peq[param.key]}
|
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
|
||||||
// onInput={(value) => onLiveValue(channel, param.key, value)}
|
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
|
||||||
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValue>)}
|
formatValue={(value) =>
|
||||||
|
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
|
||||||
|
}
|
||||||
|
value={peq.frequency}
|
||||||
|
onChange={(value) => commit({ frequency: value })}
|
||||||
|
onInput={(value) => onLiveValue({ ...peq, frequency: value })}
|
||||||
style={{ width: '100%', flex: '1 1 auto' }}
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
/>
|
/>
|
||||||
<div className="input-panel__cell-label">{param.label}</div>
|
|
||||||
|
<div className="input-panel__cell-label">Frequency</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-panel__cell input-panel__cell--q">
|
||||||
|
<VerticalSlider
|
||||||
|
min={qMin}
|
||||||
|
max={qMax}
|
||||||
|
values={PEAK_Q_VALUES}
|
||||||
|
formatValue={(v) => v.toFixed(2)}
|
||||||
|
value={peq.q}
|
||||||
|
onChange={(value) => commit({ q: value })}
|
||||||
|
onInput={(value) => onLiveValue({ ...peq, q: value })}
|
||||||
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="input-panel__cell-label">Q</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-panel__cell input-panel__cell--gain">
|
||||||
|
<VerticalSlider
|
||||||
|
min={-12}
|
||||||
|
max={12}
|
||||||
|
step={() => 0.1}
|
||||||
|
unit="dB"
|
||||||
|
value={peq.gain}
|
||||||
|
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
|
||||||
|
onChange={(value) => commit({ gain: value })}
|
||||||
|
onInput={(value) => onLiveValue({ ...peq, gain: value })}
|
||||||
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="input-panel__cell-label">Gain</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -154,7 +205,7 @@ function CrossoverPanel({
|
|||||||
const bypass = filter_type === CrossoverFilter.Bypass;
|
const bypass = filter_type === CrossoverFilter.Bypass;
|
||||||
|
|
||||||
const lastFilterType = useRef<CrossoverFilter>(
|
const lastFilterType = useRef<CrossoverFilter>(
|
||||||
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk24
|
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
|
||||||
);
|
);
|
||||||
|
|
||||||
const commit = async (overrides: {
|
const commit = async (overrides: {
|
||||||
@@ -196,7 +247,7 @@ function CrossoverPanel({
|
|||||||
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
|
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
|
||||||
value={frequency}
|
value={frequency}
|
||||||
formatValue={(value) =>
|
formatValue={(value) =>
|
||||||
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${Math.round(value)}`
|
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
|
||||||
}
|
}
|
||||||
onChange={(value) => commit({ frequency: value })}
|
onChange={(value) => commit({ frequency: value })}
|
||||||
onInput={(value) => onLiveValue(value)}
|
onInput={(value) => onLiveValue(value)}
|
||||||
@@ -208,7 +259,7 @@ function CrossoverPanel({
|
|||||||
value={bypass ? lastFilterType.current : filter_type}
|
value={bypass ? lastFilterType.current : filter_type}
|
||||||
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
|
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
|
||||||
>
|
>
|
||||||
{FILTER_TYPE_OPTIONS.map((opt) => (
|
{CROSSOVER_FILTER_LABEL.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -236,32 +287,29 @@ function CrossoverPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PeqBandList({
|
function PeqBandList({
|
||||||
channel,
|
bands,
|
||||||
dsp,
|
liveBands,
|
||||||
activeBand,
|
activeBand,
|
||||||
setActiveBand,
|
setActiveBand,
|
||||||
setPEQ,
|
setPEQ,
|
||||||
}: {
|
}: {
|
||||||
channel: InputChannel;
|
bands: Record<PEQBand, PEQ>;
|
||||||
dsp: DSP;
|
liveBands: Partial<Record<PEQBand, PEQ>>;
|
||||||
activeBand: PEQBand;
|
activeBand: PEQBand;
|
||||||
setActiveBand: (band: PEQBand) => void;
|
setActiveBand: (band: PEQBand) => void;
|
||||||
setPEQ: SetPEQFn;
|
setPEQ: (
|
||||||
|
band: PEQBand,
|
||||||
|
gainDb: number,
|
||||||
|
freqHz: number,
|
||||||
|
q: number,
|
||||||
|
filterType: PEQFilter,
|
||||||
|
bypass: boolean
|
||||||
|
) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const inputState = dsp.state.current_config.input_states[channel];
|
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
|
||||||
|
const peq = liveBands[band] ?? bands[band];
|
||||||
const commit = (band: PEQBand, overrides: Partial<PEQValue>) => {
|
|
||||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
|
||||||
const next = { ...peq, ...overrides };
|
const next = { ...peq, ...overrides };
|
||||||
return setPEQ(
|
return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||||
{ Input: channel },
|
|
||||||
band,
|
|
||||||
next.gain,
|
|
||||||
next.frequency,
|
|
||||||
next.q,
|
|
||||||
next.filter_type,
|
|
||||||
next.bypass
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,8 +323,8 @@ function PeqBandList({
|
|||||||
<div className="peq-band-list__cell">Bypass</div>
|
<div className="peq-band-list__cell">Bypass</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{PEQ_BANDS.map((band, i) => {
|
{PEQ_BANDS_ORDER.map((band, i) => {
|
||||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
const peq = liveBands[band] ?? bands[band];
|
||||||
const isActive = band === activeBand;
|
const isActive = band === activeBand;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -293,14 +341,22 @@ function PeqBandList({
|
|||||||
{`B${i + 1}`}
|
{`B${i + 1}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Frequency */}
|
||||||
<input
|
<input
|
||||||
className="peq-band-list__input"
|
className="peq-band-list__input"
|
||||||
type="number"
|
type="text"
|
||||||
step={0.5}
|
value={
|
||||||
min={19.7}
|
peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
|
||||||
max={20160}
|
}
|
||||||
value={peq.frequency}
|
onChange={(e) => {
|
||||||
onChange={(e) => commit(band, { frequency: Number(e.target.value) })}
|
const value = Number(e.target.value);
|
||||||
|
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
commit(band, {
|
||||||
|
frequency: peq.frequency >= 1000 ? value * 1000 : value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -326,9 +382,9 @@ function PeqBandList({
|
|||||||
<select
|
<select
|
||||||
className="peq-band-list__select"
|
className="peq-band-list__select"
|
||||||
value={peq.filter_type}
|
value={peq.filter_type}
|
||||||
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter })}
|
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter, q: 3.0 })}
|
||||||
>
|
>
|
||||||
{PEQ_FILTER_TYPE_OPTIONS.map((opt) => (
|
{PEQ_FILTERS_LABEL.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -356,15 +412,74 @@ type SetFilterFn = (
|
|||||||
filterType: CrossoverFilter
|
filterType: CrossoverFilter
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
type setPeqBandFn = (
|
/** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
|
||||||
channel: Channel,
|
// function GraphMarkers({
|
||||||
band: PEQBand,
|
// graphRef,
|
||||||
gainDb: number,
|
// bands,
|
||||||
freqHz: number,
|
// activeBand,
|
||||||
q: number,
|
// setActiveBand,
|
||||||
filterType: PEQFilter,
|
// hpFreq,
|
||||||
bypass: boolean
|
// hpBypassed,
|
||||||
) => Promise<boolean>;
|
// lpFreq,
|
||||||
|
// lpBypassed,
|
||||||
|
// }: {
|
||||||
|
// graphRef: React.RefObject<LinearGraphHandle>;
|
||||||
|
// bands: Record<PEQBand, PEQ>;
|
||||||
|
// activeBand: PEQBand;
|
||||||
|
// setActiveBand: (band: PEQBand) => void;
|
||||||
|
// hpFreq: number;
|
||||||
|
// hpBypassed: boolean;
|
||||||
|
// lpFreq: number;
|
||||||
|
// lpBypassed: boolean;
|
||||||
|
// }) {
|
||||||
|
// const g = graphRef.current;
|
||||||
|
// if (!g) return null;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <>
|
||||||
|
// {PEQ_BANDS_ORDER.map((band, i) => {
|
||||||
|
// const peq = bands[band];
|
||||||
|
// const left = g.toPxX(peq.frequency);
|
||||||
|
// const top = g.toPxY(peq.gain);
|
||||||
|
// const isActive = band === activeBand;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <button
|
||||||
|
// key={band}
|
||||||
|
// type="button"
|
||||||
|
// className={`graph-marker graph-marker--peq${isActive ? ' graph-marker--active' : ''}${
|
||||||
|
// peq.bypass ? ' graph-marker--bypassed' : ''
|
||||||
|
// }`}
|
||||||
|
// style={{ left, top }}
|
||||||
|
// onClick={() => setActiveBand(band)}
|
||||||
|
// title={`B${i + 1} · ${Math.round(peq.frequency)} Hz · ${peq.gain.toFixed(1)} dB`}
|
||||||
|
// >
|
||||||
|
// {`B${i + 1}`}
|
||||||
|
// </button>
|
||||||
|
// );
|
||||||
|
// })}
|
||||||
|
|
||||||
|
// <div
|
||||||
|
// className={`graph-marker graph-marker--crossover graph-marker--hp${
|
||||||
|
// hpBypassed ? ' graph-marker--bypassed' : ''
|
||||||
|
// }`}
|
||||||
|
// style={{ left: g.toPxX(hpFreq), top: g.toPxY(0) }}
|
||||||
|
// title={`HPF · ${Math.round(hpFreq)} Hz`}
|
||||||
|
// >
|
||||||
|
// HP
|
||||||
|
// </div>
|
||||||
|
// <div
|
||||||
|
// className={`graph-marker graph-marker--crossover graph-marker--lp${
|
||||||
|
// lpBypassed ? ' graph-marker--bypassed' : ''
|
||||||
|
// }`}
|
||||||
|
// style={{ left: g.toPxX(lpFreq), top: g.toPxY(0) }}
|
||||||
|
// title={`LPF · ${Math.round(lpFreq)} Hz`}
|
||||||
|
// >
|
||||||
|
// LP
|
||||||
|
// </div>
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
function InputSection({
|
function InputSection({
|
||||||
dsp,
|
dsp,
|
||||||
@@ -378,16 +493,31 @@ function InputSection({
|
|||||||
channel: InputChannel;
|
channel: InputChannel;
|
||||||
setHighPass: SetFilterFn;
|
setHighPass: SetFilterFn;
|
||||||
setLowPass: SetFilterFn;
|
setLowPass: SetFilterFn;
|
||||||
setPeq: setPeqBandFn;
|
setPeq: (
|
||||||
|
channel: Channel,
|
||||||
|
band: PEQBand,
|
||||||
|
gainDb: number,
|
||||||
|
freqHz: number,
|
||||||
|
q: number,
|
||||||
|
filterType: PEQFilter,
|
||||||
|
bypass: boolean
|
||||||
|
) => Promise<boolean>;
|
||||||
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
|
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
// UI variables
|
// UI variables
|
||||||
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
||||||
|
const [graphView, setGraphView] = useState<GraphView>('both');
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
// Live values
|
// Live values
|
||||||
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
||||||
const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
|
const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
|
||||||
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValue>>>({});
|
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQ>>>({});
|
||||||
|
|
||||||
|
// Forces one extra render after the graph mounts so marker positions
|
||||||
|
// (which read from the graph's ref) are available on first paint.
|
||||||
|
const [, forceRender] = useState(0);
|
||||||
|
const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
|
||||||
|
|
||||||
// DSP values
|
// DSP values
|
||||||
const activeInputState = dsp.state.current_config.input_states[channel];
|
const activeInputState = dsp.state.current_config.input_states[channel];
|
||||||
@@ -395,6 +525,24 @@ function InputSection({
|
|||||||
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
|
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
|
||||||
const ActivePeq = activeInputState.peq_chain;
|
const ActivePeq = activeInputState.peq_chain;
|
||||||
|
|
||||||
|
const handleResetPeq = async () => {
|
||||||
|
if (resetting) return;
|
||||||
|
setResetting(true);
|
||||||
|
|
||||||
|
for (const band of PEQ_BANDS_ORDER) {
|
||||||
|
const current = displayPeq[band];
|
||||||
|
if (current.gain === 0 || current.filter_type === PEQFilter.Peak) continue; // skip bands already flat
|
||||||
|
|
||||||
|
await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResetting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBypassClick = () => {
|
||||||
|
setPeqBypass({ Input: channel }, !ActivePeq.bypass);
|
||||||
|
};
|
||||||
|
|
||||||
// Live handlers
|
// Live handlers
|
||||||
const handleLiveHighPassValue = (value: number) => {
|
const handleLiveHighPassValue = (value: number) => {
|
||||||
setLiveHpFreq(value);
|
setLiveHpFreq(value);
|
||||||
@@ -404,7 +552,7 @@ function InputSection({
|
|||||||
setLiveLpFreq(value);
|
setLiveLpFreq(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLivePEQValue = (band: PEQBand, value: PEQValue) => {
|
const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
|
||||||
setLivePeq((prev) => ({
|
setLivePeq((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[band]: value,
|
[band]: value,
|
||||||
@@ -439,21 +587,36 @@ function InputSection({
|
|||||||
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
|
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
|
||||||
if (success) {
|
if (success) {
|
||||||
setLivePeq((prev) => {
|
setLivePeq((prev) => {
|
||||||
const channelLive = prev[band];
|
const next = { ...prev };
|
||||||
if (!channelLive || !(band in channelLive)) return prev;
|
delete next[band];
|
||||||
const next = { ...channelLive };
|
return next;
|
||||||
return { ...prev, [channel]: next };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Diplay value (merge live and real)
|
||||||
|
const displayPeq: Record<PEQBand, PEQ> = {
|
||||||
|
...ActivePeq.bands,
|
||||||
|
...livePeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope };
|
||||||
|
const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="input-graph-section">
|
<div className="input-graph-section">
|
||||||
<div className="input-graph-panel">
|
<div className="input-graph-panel">
|
||||||
|
<div className="input-graph__title-row">
|
||||||
<div className="input-graph__title">{activeInputState.name} - Filters</div>
|
<div className="input-graph__title">{activeInputState.name} - Filters</div>
|
||||||
|
<GraphViewToggle view={graphView} onChange={setGraphView} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(graphView === 'both' || graphView === 'magnitude') && (
|
||||||
|
<div className="input-graph-panel__magnitude-wrap">
|
||||||
<LinearGraph
|
<LinearGraph
|
||||||
|
ref={magnitudeGraphRef}
|
||||||
xMin={GRAPH_X_MIN}
|
xMin={GRAPH_X_MIN}
|
||||||
xMax={GRAPH_X_MAX}
|
xMax={GRAPH_X_MAX}
|
||||||
xScale="log"
|
xScale="log"
|
||||||
@@ -465,36 +628,88 @@ 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={() => {}}
|
draw={buildTotalCurveDraw({
|
||||||
|
bands: displayPeq,
|
||||||
|
highPass: highPassSpec,
|
||||||
|
lowPass: lowPassSpec,
|
||||||
|
xMin: GRAPH_X_MIN,
|
||||||
|
xMax: GRAPH_X_MAX,
|
||||||
|
yMin: GRAPH_Y_MIN,
|
||||||
|
yMax: GRAPH_Y_MAX,
|
||||||
|
dimmed: ActivePeq.bypass,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(graphView === 'both' || graphView === 'phase') && (
|
||||||
|
<LinearGraph
|
||||||
|
xMin={GRAPH_X_MIN}
|
||||||
|
xMax={GRAPH_X_MAX}
|
||||||
|
xScale="log"
|
||||||
|
yMin={-180}
|
||||||
|
yMax={180}
|
||||||
|
yStep={45}
|
||||||
|
xLabel="Hz"
|
||||||
|
yLabel="°"
|
||||||
|
showGrid={true}
|
||||||
|
formatX={(v) => Math.round(v).toString()}
|
||||||
|
formatY={(v) => Math.round(v).toString()}
|
||||||
|
draw={buildTotalPhaseCurveDraw({
|
||||||
|
bands: displayPeq,
|
||||||
|
highPass: highPassSpec,
|
||||||
|
lowPass: lowPassSpec,
|
||||||
|
xMin: GRAPH_X_MIN,
|
||||||
|
xMax: GRAPH_X_MAX,
|
||||||
|
yMin: -180,
|
||||||
|
yMax: 180,
|
||||||
|
dimmed: ActivePeq.bypass,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-control-section">
|
<div className="input-control-section">
|
||||||
<div className="input-control-panel">
|
<div className="input-control-panel">
|
||||||
<ChannelGroup title="PEQ">
|
<ChannelGroup title="PEQ">
|
||||||
<PeqBandList
|
<PeqBandList
|
||||||
channel={channel}
|
bands={ActivePeq.bands}
|
||||||
dsp={dsp}
|
liveBands={livePeq}
|
||||||
activeBand={activeBand}
|
activeBand={activeBand}
|
||||||
setActiveBand={setActiveBand}
|
setActiveBand={setActiveBand}
|
||||||
setPEQ={setPeq}
|
setPEQ={commitPEQ}
|
||||||
|
/>
|
||||||
|
<PeqPanelSliders
|
||||||
|
peq={ActivePeq.bands[activeBand]}
|
||||||
|
setPEQ={(gain, frequency, q, filter_type, bypass) =>
|
||||||
|
commitPEQ(activeBand, gain, frequency, q, filter_type, bypass)
|
||||||
|
}
|
||||||
|
onLiveValue={(value) => handleLivePEQValue(activeBand, value)}
|
||||||
/>
|
/>
|
||||||
<PeqPanelSliders channel={channel} dsp={dsp} band={activeBand} setPEQ={setPeq} />
|
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
|
|
||||||
<ChannelGroup title="CrossOver">
|
<ChannelGroup title="CrossOver">
|
||||||
|
<CrossoverPanel
|
||||||
|
crossover={ActiveHighPassFilter}
|
||||||
|
title="HighPass"
|
||||||
|
setCrossover={commitHighPass}
|
||||||
|
onLiveValue={handleLiveHighPassValue}
|
||||||
|
/>
|
||||||
<CrossoverPanel
|
<CrossoverPanel
|
||||||
crossover={ActiveLowPassFilter}
|
crossover={ActiveLowPassFilter}
|
||||||
title="LowPass"
|
title="LowPass"
|
||||||
setCrossover={commitLowPass}
|
setCrossover={commitLowPass}
|
||||||
onLiveValue={handleLiveLowPassValue}
|
onLiveValue={handleLiveLowPassValue}
|
||||||
/>
|
/>
|
||||||
<CrossoverPanel
|
</ChannelGroup>
|
||||||
crossover={ActiveHighPassFilter}
|
|
||||||
title="HighPass"
|
<ChannelGroup title="Actions">
|
||||||
setCrossover={commitHighPass}
|
<InputActions
|
||||||
onLiveValue={handleLiveHighPassValue}
|
bypass={ActivePeq.bypass}
|
||||||
|
resetting={resetting}
|
||||||
|
onBypass={handleBypassClick}
|
||||||
|
onReset={handleResetPeq}
|
||||||
/>
|
/>
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { DiscreteFrequency, InputChannel, OutputChannel } from './types';
|
import {
|
||||||
|
DiscreteFrequency,
|
||||||
|
InputChannel,
|
||||||
|
OutputChannel,
|
||||||
|
CrossoverFilter,
|
||||||
|
PEQBand,
|
||||||
|
PEQFilter,
|
||||||
|
DelayUnit,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
export const INPUT_ORDER: InputChannel[] = [
|
export const INPUT_ORDER: InputChannel[] = [
|
||||||
InputChannel.InA,
|
InputChannel.InA,
|
||||||
@@ -94,6 +102,66 @@ export const GEQ_GAIN_MAX = 12;
|
|||||||
export const GEQ_GAIN_STEP = 0.1;
|
export const GEQ_GAIN_STEP = 0.1;
|
||||||
|
|
||||||
// ------ Filter constants ------
|
// ------ Filter constants ------
|
||||||
|
|
||||||
|
export const CROSSOVER_FILTER_LABEL: { value: CrossoverFilter; label: string }[] = [
|
||||||
|
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
|
||||||
|
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
|
||||||
|
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
|
||||||
|
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
|
||||||
|
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
|
||||||
|
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
|
||||||
|
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
|
||||||
|
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
|
||||||
|
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
|
||||||
|
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
|
||||||
|
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
|
||||||
|
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PEQ_BANDS_ORDER: PEQBand[] = [
|
||||||
|
PEQBand.B1,
|
||||||
|
PEQBand.B2,
|
||||||
|
PEQBand.B3,
|
||||||
|
PEQBand.B4,
|
||||||
|
PEQBand.B5,
|
||||||
|
PEQBand.B6,
|
||||||
|
PEQBand.B7,
|
||||||
|
PEQBand.B8,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PEQ_Q_RANGES: Record<PEQFilter, readonly [number, number]> = {
|
||||||
|
[PEQFilter.Peak]: [0.4, 128.0],
|
||||||
|
[PEQFilter.LowShelf]: [0.4, 3.0],
|
||||||
|
[PEQFilter.HighShelf]: [0.4, 3.0],
|
||||||
|
[PEQFilter.Lp6Db]: [0.4, 3.0],
|
||||||
|
[PEQFilter.Lp12Db]: [0.4, 3.0],
|
||||||
|
[PEQFilter.Hp6Db]: [0.4, 3.0],
|
||||||
|
[PEQFilter.Hp12Db]: [0.4, 3.0],
|
||||||
|
[PEQFilter.AllPass1]: [0.4, 128.0],
|
||||||
|
[PEQFilter.AllPass2]: [0.4, 128.0],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PEAK_Q_VALUES = [
|
||||||
|
0.4, 0.42, 0.45, 0.47, 0.5, 0.53, 0.56, 0.59, 0.63, 0.67, 0.71, 0.75, 0.79, 0.84, 0.89, 0.94, 1.0,
|
||||||
|
1.06, 1.12, 1.19, 1.26, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.4, 2.5, 2.7, 2.8, 3.0,
|
||||||
|
3.2, 3.4, 3.6, 3.8, 4.0, 4.2, 4.5, 4.8, 5.0, 5.3, 5.7, 6.0, 6.3, 6.7, 7.1, 7.6, 8.0, 8.5, 9.0,
|
||||||
|
9.5, 10.1, 10.7, 11.3, 12.0, 12.7, 13.5, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 23.0,
|
||||||
|
24.0, 25.0, 27.0, 29.0, 30.0, 32.0, 34.0, 36.0, 38.0, 40.0, 43.0, 45.0, 48.0, 51.0, 54.0, 57.0,
|
||||||
|
60.0, 64.0, 68.0, 72.0, 76.0, 81.0, 85.0, 91.0, 96.0, 102.0, 108.0, 114.0, 121.0, 128.0,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PEQ_FILTERS_LABEL: { value: PEQFilter; label: string }[] = [
|
||||||
|
{ value: PEQFilter.Peak, label: 'Peak' },
|
||||||
|
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
|
||||||
|
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
|
||||||
|
{ value: PEQFilter.Lp6Db, label: 'Low Pass 6 dB/oct' },
|
||||||
|
{ value: PEQFilter.Lp12Db, label: 'Low Pass 12 dB/oct' },
|
||||||
|
{ value: PEQFilter.Hp6Db, label: 'High Pass 6 dB/oct' },
|
||||||
|
{ value: PEQFilter.Hp12Db, label: 'High Pass 12 dB/oct' },
|
||||||
|
{ value: PEQFilter.AllPass1, label: 'All Pass 1' },
|
||||||
|
{ value: PEQFilter.AllPass2, label: 'All Pass 2' },
|
||||||
|
];
|
||||||
|
|
||||||
export const FREQUENCY_MIN = 19.7;
|
export const FREQUENCY_MIN = 19.7;
|
||||||
export const FREQUENCY_MAX = 20160;
|
export const FREQUENCY_MAX = 20160;
|
||||||
export const FREQUENCY_QUANTIZATION = 300;
|
export const FREQUENCY_QUANTIZATION = 300;
|
||||||
@@ -103,6 +171,13 @@ export const MATRIX_GAIN_MIN = -60;
|
|||||||
export const MATRIX_GAIN_MAX = 0;
|
export const MATRIX_GAIN_MAX = 0;
|
||||||
export const MATRIX_GAIN_STEP = 0.1;
|
export const MATRIX_GAIN_STEP = 0.1;
|
||||||
|
|
||||||
|
// ------ Delay constants ------
|
||||||
|
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
|
||||||
|
[DelayUnit.Millisecond]: [0, 680.0],
|
||||||
|
[DelayUnit.Meter]: [0, 233.58],
|
||||||
|
[DelayUnit.Feet]: [0, 766.329],
|
||||||
|
};
|
||||||
|
|
||||||
// ------ Graph constants ------
|
// ------ Graph constants ------
|
||||||
export const GRAPH_X_MIN = 10;
|
export const GRAPH_X_MIN = 10;
|
||||||
export const GRAPH_X_MAX = 30000;
|
export const GRAPH_X_MAX = 30000;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
Gate,
|
||||||
|
GraphicEQ,
|
||||||
|
DiscreteFrequency,
|
||||||
|
PEQ,
|
||||||
|
PEQFilter,
|
||||||
|
PEQBand,
|
||||||
|
PEQChain,
|
||||||
|
Crossover,
|
||||||
|
CrossoverFilter,
|
||||||
|
CrossoverFilters,
|
||||||
|
InputChannelState,
|
||||||
|
OutputChannelState,
|
||||||
|
InputChannel,
|
||||||
|
DSPState,
|
||||||
|
Meters,
|
||||||
|
Meter,
|
||||||
|
Ratio,
|
||||||
|
OutputChannel,
|
||||||
|
DelayUnit,
|
||||||
|
} from './types';
|
||||||
|
import { FREQUENCY_MIN, FREQUENCY_MAX } from './constants';
|
||||||
|
|
||||||
|
const createDefaultGate = (): Gate => ({
|
||||||
|
attack: 10,
|
||||||
|
release: 10,
|
||||||
|
hold: 10,
|
||||||
|
threshold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultGraphicEQ = (): GraphicEQ => ({
|
||||||
|
gains: {
|
||||||
|
[DiscreteFrequency.F20]: 0,
|
||||||
|
[DiscreteFrequency.F25]: 0,
|
||||||
|
[DiscreteFrequency.F31_5]: 0,
|
||||||
|
[DiscreteFrequency.F40]: 0,
|
||||||
|
[DiscreteFrequency.F50]: 0,
|
||||||
|
[DiscreteFrequency.F63]: 0,
|
||||||
|
[DiscreteFrequency.F80]: 0,
|
||||||
|
[DiscreteFrequency.F100]: 0,
|
||||||
|
[DiscreteFrequency.F125]: 0,
|
||||||
|
[DiscreteFrequency.F160]: 0,
|
||||||
|
[DiscreteFrequency.F200]: 0,
|
||||||
|
[DiscreteFrequency.F250]: 0,
|
||||||
|
[DiscreteFrequency.F315]: 0,
|
||||||
|
[DiscreteFrequency.F400]: 0,
|
||||||
|
[DiscreteFrequency.F500]: 0,
|
||||||
|
[DiscreteFrequency.F630]: 0,
|
||||||
|
[DiscreteFrequency.F800]: 0,
|
||||||
|
[DiscreteFrequency.F1000]: 0,
|
||||||
|
[DiscreteFrequency.F1250]: 0,
|
||||||
|
[DiscreteFrequency.F1600]: 0,
|
||||||
|
[DiscreteFrequency.F2000]: 0,
|
||||||
|
[DiscreteFrequency.F2500]: 0,
|
||||||
|
[DiscreteFrequency.F3150]: 0,
|
||||||
|
[DiscreteFrequency.F4000]: 0,
|
||||||
|
[DiscreteFrequency.F5000]: 0,
|
||||||
|
[DiscreteFrequency.F6300]: 0,
|
||||||
|
[DiscreteFrequency.F8000]: 0,
|
||||||
|
[DiscreteFrequency.F10000]: 0,
|
||||||
|
[DiscreteFrequency.F12500]: 0,
|
||||||
|
[DiscreteFrequency.F16000]: 0,
|
||||||
|
[DiscreteFrequency.F20000]: 0,
|
||||||
|
},
|
||||||
|
bypass: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultPEQ = (): PEQ => ({
|
||||||
|
gain: 0,
|
||||||
|
frequency: 1000,
|
||||||
|
q: 1,
|
||||||
|
filter_type: PEQFilter.Peak,
|
||||||
|
bypass: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultPEQChain = (): PEQChain => ({
|
||||||
|
bands: Object.fromEntries(
|
||||||
|
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
|
||||||
|
) as Record<PEQBand, PEQ>,
|
||||||
|
bypass: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultCrossover = (frequency: number): Crossover => ({
|
||||||
|
frequency: frequency,
|
||||||
|
slope: CrossoverFilter.Bypass,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
|
||||||
|
high_pass: createDefaultCrossover(FREQUENCY_MIN),
|
||||||
|
low_pass: createDefaultCrossover(FREQUENCY_MAX),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultInputChannelState = (name: string): InputChannelState => ({
|
||||||
|
name,
|
||||||
|
gain: 0,
|
||||||
|
phase_inverted: false,
|
||||||
|
delay: 0,
|
||||||
|
|
||||||
|
gate: createDefaultGate(),
|
||||||
|
geq: createDefaultGraphicEQ(),
|
||||||
|
peq_chain: createDefaultPEQChain(),
|
||||||
|
crossover: createDefaultCrossoverFilters(),
|
||||||
|
|
||||||
|
linked_channels: [],
|
||||||
|
|
||||||
|
mute: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
|
||||||
|
name,
|
||||||
|
|
||||||
|
matrix_routes: {
|
||||||
|
connected: [InputChannel.InA],
|
||||||
|
gains: {
|
||||||
|
[InputChannel.InA]: 0,
|
||||||
|
[InputChannel.InB]: 0,
|
||||||
|
[InputChannel.InC]: 0,
|
||||||
|
[InputChannel.InD]: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
crossover: createDefaultCrossoverFilters(),
|
||||||
|
peq_chain: createDefaultPEQChain(),
|
||||||
|
|
||||||
|
compressor: {
|
||||||
|
threshold: -90,
|
||||||
|
ratio: Ratio.Ratio1_1,
|
||||||
|
attack: 1,
|
||||||
|
release: 10,
|
||||||
|
knee: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
limiter: {
|
||||||
|
threshold: -90,
|
||||||
|
attack: 1,
|
||||||
|
release: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
gain: 0,
|
||||||
|
phase_inverted: false,
|
||||||
|
delay: 0,
|
||||||
|
|
||||||
|
linked_channels: [],
|
||||||
|
|
||||||
|
mute: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultDSPState: DSPState = {
|
||||||
|
name: 'Default',
|
||||||
|
flags: {
|
||||||
|
is_locked: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
presets: {
|
||||||
|
current_index: 0,
|
||||||
|
names: Array(20).fill('Default'),
|
||||||
|
modified: Array(20).fill(false),
|
||||||
|
},
|
||||||
|
|
||||||
|
current_config: {
|
||||||
|
input_source: {
|
||||||
|
type: 'Analog',
|
||||||
|
frequency: '48kHz',
|
||||||
|
},
|
||||||
|
|
||||||
|
delay_unit: DelayUnit.Millisecond,
|
||||||
|
|
||||||
|
input_states: {
|
||||||
|
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
|
||||||
|
[InputChannel.InB]: createDefaultInputChannelState('In B'),
|
||||||
|
[InputChannel.InC]: createDefaultInputChannelState('In C'),
|
||||||
|
[InputChannel.InD]: createDefaultInputChannelState('In D'),
|
||||||
|
},
|
||||||
|
|
||||||
|
output_states: {
|
||||||
|
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
|
||||||
|
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
|
||||||
|
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
|
||||||
|
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
|
||||||
|
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
|
||||||
|
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
|
||||||
|
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
|
||||||
|
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDefaultMeter = (): Meter => ({
|
||||||
|
rms: -60,
|
||||||
|
peak: -60,
|
||||||
|
clip: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultMeters: Meters = {
|
||||||
|
channels: Array.from({ length: 12 }, createDefaultMeter),
|
||||||
|
};
|
||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
|
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
|
||||||
import { registerPollStopper } from '../../dsp/pollRegistry';
|
import { registerPollStopper } from '../../dsp/pollRegistry';
|
||||||
import { defaultDSPState, defaultMeters } from './types';
|
import { defaultDSPState, defaultMeters } from './defaults';
|
||||||
import { DELAY_RANGES } from './types';
|
import { DELAY_RANGES, PEQ_Q_RANGES } from './constants';
|
||||||
|
|
||||||
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
|
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
|
||||||
|
|
||||||
@@ -1325,20 +1325,40 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
bypass: boolean
|
bypass: boolean
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const updatePeqBand = () => {
|
const updatePeqBand = () => {
|
||||||
|
function convertPeqQ(value: number, from: PEQFilter, to: PEQFilter): number {
|
||||||
|
if (from === to) return value;
|
||||||
|
|
||||||
|
const [fromMin, fromMax] = PEQ_Q_RANGES[from];
|
||||||
|
const [toMin, toMax] = PEQ_Q_RANGES[to];
|
||||||
|
|
||||||
|
if (fromMax === fromMin) return toMin;
|
||||||
|
|
||||||
|
const ratio = (value - fromMin) / (fromMax - fromMin);
|
||||||
|
|
||||||
|
return toMin + ratio * (toMax - toMin);
|
||||||
|
}
|
||||||
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => {
|
updateDSP(setDsps, dsp.id, (d) => {
|
||||||
if (!d.state) return d;
|
if (!d.state) return d;
|
||||||
|
|
||||||
const cfg = d.state.current_config;
|
const cfg = d.state.current_config;
|
||||||
const peqBand = {
|
|
||||||
gain: gainDb,
|
|
||||||
frequency: freqHz,
|
|
||||||
q,
|
|
||||||
filter_type: filterType,
|
|
||||||
bypass,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ('Input' in channel) {
|
if ('Input' in channel) {
|
||||||
const inputState = cfg.input_states[channel.Input];
|
const inputState = cfg.input_states[channel.Input];
|
||||||
|
const existingBand = inputState.peq_chain.bands[band];
|
||||||
|
|
||||||
|
const nextQ =
|
||||||
|
existingBand.filter_type !== filterType
|
||||||
|
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
|
||||||
|
: q;
|
||||||
|
|
||||||
|
const peqBand = {
|
||||||
|
gain: gainDb,
|
||||||
|
frequency: freqHz,
|
||||||
|
q: nextQ,
|
||||||
|
filter_type: filterType,
|
||||||
|
bypass,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
@@ -1365,6 +1385,20 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outputState = cfg.output_states[channel.Output];
|
const outputState = cfg.output_states[channel.Output];
|
||||||
|
const existingBand = outputState.peq_chain.bands[band];
|
||||||
|
|
||||||
|
const nextQ =
|
||||||
|
existingBand.filter_type !== filterType
|
||||||
|
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
|
||||||
|
: q;
|
||||||
|
|
||||||
|
const peqBand = {
|
||||||
|
gain: gainDb,
|
||||||
|
frequency: freqHz,
|
||||||
|
q: nextQ,
|
||||||
|
filter_type: filterType,
|
||||||
|
bypass,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { FREQUENCY_MAX, FREQUENCY_MIN } from './constants';
|
||||||
|
|
||||||
export enum DelayUnit {
|
export enum DelayUnit {
|
||||||
Millisecond = 'Millisecond',
|
Millisecond = 'Millisecond',
|
||||||
Meter = 'Meter',
|
Meter = 'Meter',
|
||||||
@@ -247,185 +249,3 @@ export interface Meter {
|
|||||||
export interface Meters {
|
export interface Meters {
|
||||||
channels: Meter[];
|
channels: Meter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Default Value -- */
|
|
||||||
|
|
||||||
const createDefaultGate = (): Gate => ({
|
|
||||||
attack: 10,
|
|
||||||
release: 10,
|
|
||||||
hold: 10,
|
|
||||||
threshold: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultGraphicEQ = (): GraphicEQ => ({
|
|
||||||
gains: {
|
|
||||||
[DiscreteFrequency.F20]: 0,
|
|
||||||
[DiscreteFrequency.F25]: 0,
|
|
||||||
[DiscreteFrequency.F31_5]: 0,
|
|
||||||
[DiscreteFrequency.F40]: 0,
|
|
||||||
[DiscreteFrequency.F50]: 0,
|
|
||||||
[DiscreteFrequency.F63]: 0,
|
|
||||||
[DiscreteFrequency.F80]: 0,
|
|
||||||
[DiscreteFrequency.F100]: 0,
|
|
||||||
[DiscreteFrequency.F125]: 0,
|
|
||||||
[DiscreteFrequency.F160]: 0,
|
|
||||||
[DiscreteFrequency.F200]: 0,
|
|
||||||
[DiscreteFrequency.F250]: 0,
|
|
||||||
[DiscreteFrequency.F315]: 0,
|
|
||||||
[DiscreteFrequency.F400]: 0,
|
|
||||||
[DiscreteFrequency.F500]: 0,
|
|
||||||
[DiscreteFrequency.F630]: 0,
|
|
||||||
[DiscreteFrequency.F800]: 0,
|
|
||||||
[DiscreteFrequency.F1000]: 0,
|
|
||||||
[DiscreteFrequency.F1250]: 0,
|
|
||||||
[DiscreteFrequency.F1600]: 0,
|
|
||||||
[DiscreteFrequency.F2000]: 0,
|
|
||||||
[DiscreteFrequency.F2500]: 0,
|
|
||||||
[DiscreteFrequency.F3150]: 0,
|
|
||||||
[DiscreteFrequency.F4000]: 0,
|
|
||||||
[DiscreteFrequency.F5000]: 0,
|
|
||||||
[DiscreteFrequency.F6300]: 0,
|
|
||||||
[DiscreteFrequency.F8000]: 0,
|
|
||||||
[DiscreteFrequency.F10000]: 0,
|
|
||||||
[DiscreteFrequency.F12500]: 0,
|
|
||||||
[DiscreteFrequency.F16000]: 0,
|
|
||||||
[DiscreteFrequency.F20000]: 0,
|
|
||||||
},
|
|
||||||
bypass: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultPEQ = (): PEQ => ({
|
|
||||||
gain: 0,
|
|
||||||
frequency: 1000,
|
|
||||||
q: 1,
|
|
||||||
filter_type: PEQFilter.Peak,
|
|
||||||
bypass: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultPEQChain = (): PEQChain => ({
|
|
||||||
bands: Object.fromEntries(
|
|
||||||
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
|
|
||||||
) as Record<PEQBand, PEQ>,
|
|
||||||
bypass: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultCrossover = (): Crossover => ({
|
|
||||||
frequency: 20000,
|
|
||||||
slope: CrossoverFilter.Bypass,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
|
|
||||||
high_pass: createDefaultCrossover(),
|
|
||||||
low_pass: createDefaultCrossover(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultInputChannelState = (name: string): InputChannelState => ({
|
|
||||||
name,
|
|
||||||
gain: 0,
|
|
||||||
phase_inverted: false,
|
|
||||||
delay: 0,
|
|
||||||
|
|
||||||
gate: createDefaultGate(),
|
|
||||||
geq: createDefaultGraphicEQ(),
|
|
||||||
peq_chain: createDefaultPEQChain(),
|
|
||||||
crossover: createDefaultCrossoverFilters(),
|
|
||||||
|
|
||||||
linked_channels: [],
|
|
||||||
|
|
||||||
mute: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
|
|
||||||
name,
|
|
||||||
|
|
||||||
matrix_routes: {
|
|
||||||
connected: [InputChannel.InA],
|
|
||||||
gains: {
|
|
||||||
[InputChannel.InA]: 0,
|
|
||||||
[InputChannel.InB]: 0,
|
|
||||||
[InputChannel.InC]: 0,
|
|
||||||
[InputChannel.InD]: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
crossover: createDefaultCrossoverFilters(),
|
|
||||||
peq_chain: createDefaultPEQChain(),
|
|
||||||
|
|
||||||
compressor: {
|
|
||||||
threshold: -90,
|
|
||||||
ratio: Ratio.Ratio1_1,
|
|
||||||
attack: 1,
|
|
||||||
release: 10,
|
|
||||||
knee: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
limiter: {
|
|
||||||
threshold: -90,
|
|
||||||
attack: 1,
|
|
||||||
release: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
gain: 0,
|
|
||||||
phase_inverted: false,
|
|
||||||
delay: 0,
|
|
||||||
|
|
||||||
linked_channels: [],
|
|
||||||
|
|
||||||
mute: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const defaultDSPState: DSPState = {
|
|
||||||
name: 'Default',
|
|
||||||
flags: {
|
|
||||||
is_locked: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
presets: {
|
|
||||||
current_index: 0,
|
|
||||||
names: Array(20).fill('Default'),
|
|
||||||
modified: Array(20).fill(false),
|
|
||||||
},
|
|
||||||
|
|
||||||
current_config: {
|
|
||||||
input_source: {
|
|
||||||
type: 'Analog',
|
|
||||||
frequency: '48kHz',
|
|
||||||
},
|
|
||||||
|
|
||||||
delay_unit: DelayUnit.Millisecond,
|
|
||||||
|
|
||||||
input_states: {
|
|
||||||
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
|
|
||||||
[InputChannel.InB]: createDefaultInputChannelState('In B'),
|
|
||||||
[InputChannel.InC]: createDefaultInputChannelState('In C'),
|
|
||||||
[InputChannel.InD]: createDefaultInputChannelState('In D'),
|
|
||||||
},
|
|
||||||
|
|
||||||
output_states: {
|
|
||||||
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
|
|
||||||
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
|
|
||||||
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
|
|
||||||
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
|
|
||||||
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
|
|
||||||
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
|
|
||||||
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
|
|
||||||
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDefaultMeter = (): Meter => ({
|
|
||||||
rms: -60,
|
|
||||||
peak: -60,
|
|
||||||
clip: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const defaultMeters: Meters = {
|
|
||||||
channels: Array.from({ length: 12 }, createDefaultMeter),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
|
|
||||||
[DelayUnit.Millisecond]: [0, 680.0],
|
|
||||||
[DelayUnit.Meter]: [0, 233.58],
|
|
||||||
[DelayUnit.Feet]: [0, 766.329],
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export const DEFAULT_SAMPLE_RATE = 96000;
|
||||||
|
|
||||||
|
export type BiquadCoeffs = {
|
||||||
|
b0: number;
|
||||||
|
b1: number;
|
||||||
|
b2: number;
|
||||||
|
a0: number;
|
||||||
|
a1: number;
|
||||||
|
a2: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Exact magnitude response (dB) of a digital biquad, evaluated on the unit circle. */
|
||||||
|
export function biquadMagnitudeDb(
|
||||||
|
freq: number,
|
||||||
|
fs: number,
|
||||||
|
{ b0, b1, b2, a0, a1, a2 }: BiquadCoeffs
|
||||||
|
): number {
|
||||||
|
const w = (2 * Math.PI * freq) / fs;
|
||||||
|
const cosw = Math.cos(w);
|
||||||
|
const sinw = Math.sin(w);
|
||||||
|
const cos2w = Math.cos(2 * w);
|
||||||
|
const sin2w = Math.sin(2 * w);
|
||||||
|
|
||||||
|
const numRe = b0 + b1 * cosw + b2 * cos2w;
|
||||||
|
const numIm = -b1 * sinw - b2 * sin2w;
|
||||||
|
const denRe = a0 + a1 * cosw + a2 * cos2w;
|
||||||
|
const denIm = -a1 * sinw - a2 * sin2w;
|
||||||
|
|
||||||
|
return 20 * Math.log10(Math.hypot(numRe, numIm) / Math.hypot(denRe, denIm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact phase response (degrees) of a digital biquad. */
|
||||||
|
export function biquadPhaseDeg(
|
||||||
|
freq: number,
|
||||||
|
fs: number,
|
||||||
|
{ b0, b1, b2, a0, a1, a2 }: BiquadCoeffs
|
||||||
|
): number {
|
||||||
|
const w = (2 * Math.PI * freq) / fs;
|
||||||
|
const cosw = Math.cos(w);
|
||||||
|
const sinw = Math.sin(w);
|
||||||
|
const cos2w = Math.cos(2 * w);
|
||||||
|
const sin2w = Math.sin(2 * w);
|
||||||
|
|
||||||
|
const numRe = b0 + b1 * cosw + b2 * cos2w;
|
||||||
|
const numIm = -b1 * sinw - b2 * sin2w;
|
||||||
|
const denRe = a0 + a1 * cosw + a2 * cos2w;
|
||||||
|
const denIm = -a1 * sinw - a2 * sin2w;
|
||||||
|
|
||||||
|
return ((Math.atan2(numIm, numRe) - Math.atan2(denIm, denRe)) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------- Shared simple filter primitives (used by both PEQ Lp6/Hp6/Lp12/Hp12
|
||||||
|
// filter types AND crossover Butterworth stage composition) -----------
|
||||||
|
|
||||||
|
/** 1-pole lowpass, -6dB/oct. */
|
||||||
|
export function lp6ResponseDb(freq: number, f0: number): number {
|
||||||
|
const x = freq / f0;
|
||||||
|
return -10 * Math.log10(1 + x * x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-pole highpass, -6dB/oct. */
|
||||||
|
export function hp6ResponseDb(freq: number, f0: number): number {
|
||||||
|
const x = f0 / freq;
|
||||||
|
return -10 * Math.log10(1 + x * x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2-pole lowpass, -12dB/oct, resonant. */
|
||||||
|
export 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. */
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lp6PhaseDeg(freq: number, f0: number): number {
|
||||||
|
return -Math.atan(freq / f0) * (180 / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hp6PhaseDeg(freq: number, f0: number): number {
|
||||||
|
return Math.atan(f0 / freq) * (180 / Math.PI) - 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lp12PhaseDeg(freq: number, f0: number, q: number): number {
|
||||||
|
const x = freq / f0;
|
||||||
|
return -Math.atan2(x / q, 1 - x * x) * (180 / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hp12PhaseDeg(freq: number, f0: number, q: number): number {
|
||||||
|
const x = freq / f0;
|
||||||
|
return (Math.PI - Math.atan2(x / q, 1 - x * x)) * (180 / Math.PI);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { CrossoverFilter } from '../types';
|
||||||
|
import {
|
||||||
|
lp6ResponseDb,
|
||||||
|
hp6ResponseDb,
|
||||||
|
lp12ResponseDb,
|
||||||
|
hp12ResponseDb,
|
||||||
|
lp6PhaseDeg,
|
||||||
|
hp6PhaseDeg,
|
||||||
|
lp12PhaseDeg,
|
||||||
|
hp12PhaseDeg,
|
||||||
|
} from './biquad';
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkwitzRileyLpResponseDb = (f: number, f0: number, o: number) =>
|
||||||
|
2 * butterworthLpResponseDb(f, f0, o / 2);
|
||||||
|
|
||||||
|
const linkwitzRileyHpResponseDb = (f: number, f0: number, o: number) =>
|
||||||
|
2 * butterworthHpResponseDb(f, f0, o / 2);
|
||||||
|
|
||||||
|
const linkwitzRileyLpPhaseDeg = (f: number, f0: number, o: number) =>
|
||||||
|
2 * butterworthLpPhaseDeg(f, f0, o / 2);
|
||||||
|
|
||||||
|
const linkwitzRileyHpPhaseDeg = (f: number, f0: number, o: number) =>
|
||||||
|
2 * butterworthHpPhaseDeg(f, f0, o / 2);
|
||||||
|
|
||||||
|
type CrossoverTopology = 'butterworth' | 'linkwitz-riley';
|
||||||
|
|
||||||
|
const CROSSOVER_SLOPE_CONFIG: Record<
|
||||||
|
Exclude<CrossoverFilter, CrossoverFilter.Bypass>,
|
||||||
|
{ topology: CrossoverTopology; order: number }
|
||||||
|
> = {
|
||||||
|
[CrossoverFilter.Bw6]: { topology: 'butterworth', order: 1 },
|
||||||
|
[CrossoverFilter.Bw12]: { topology: 'butterworth', order: 2 },
|
||||||
|
[CrossoverFilter.Bw18]: { topology: 'butterworth', order: 3 },
|
||||||
|
[CrossoverFilter.Bw24]: { topology: 'butterworth', order: 4 },
|
||||||
|
[CrossoverFilter.Bw30]: { topology: 'butterworth', order: 5 },
|
||||||
|
[CrossoverFilter.Bw36]: { topology: 'butterworth', order: 6 },
|
||||||
|
[CrossoverFilter.Bw42]: { topology: 'butterworth', order: 7 },
|
||||||
|
[CrossoverFilter.Bw48]: { topology: 'butterworth', order: 8 },
|
||||||
|
[CrossoverFilter.Lk12]: { topology: 'linkwitz-riley', order: 2 },
|
||||||
|
[CrossoverFilter.Lk24]: { topology: 'linkwitz-riley', order: 4 },
|
||||||
|
[CrossoverFilter.Lk36]: { topology: 'linkwitz-riley', order: 6 },
|
||||||
|
[CrossoverFilter.Lk48]: { topology: 'linkwitz-riley', order: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrossoverKind = 'lp' | 'hp';
|
||||||
|
export interface CrossoverSpec {
|
||||||
|
frequency: number;
|
||||||
|
filter_type: CrossoverFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossoverMagnitudeDb(
|
||||||
|
kind: CrossoverKind,
|
||||||
|
spec: CrossoverSpec | undefined,
|
||||||
|
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'
|
||||||
|
? butterworthLpResponseDb
|
||||||
|
: linkwitzRileyLpResponseDb
|
||||||
|
: cfg.topology === 'butterworth'
|
||||||
|
? butterworthHpResponseDb
|
||||||
|
: linkwitzRileyHpResponseDb;
|
||||||
|
return fn(freq, spec.frequency, cfg.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossoverPhaseDeg(
|
||||||
|
kind: CrossoverKind,
|
||||||
|
spec: CrossoverSpec | undefined,
|
||||||
|
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'
|
||||||
|
? butterworthLpPhaseDeg
|
||||||
|
: linkwitzRileyLpPhaseDeg
|
||||||
|
: cfg.topology === 'butterworth'
|
||||||
|
? butterworthHpPhaseDeg
|
||||||
|
: linkwitzRileyHpPhaseDeg;
|
||||||
|
return fn(freq, spec.frequency, cfg.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossoverPairMagnitudeDb(
|
||||||
|
highPass: CrossoverSpec | undefined,
|
||||||
|
lowPass: CrossoverSpec | undefined,
|
||||||
|
freq: number
|
||||||
|
): number {
|
||||||
|
return crossoverMagnitudeDb('hp', highPass, freq) + crossoverMagnitudeDb('lp', lowPass, freq);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossoverPairPhaseDeg(
|
||||||
|
highPass: CrossoverSpec | undefined,
|
||||||
|
lowPass: CrossoverSpec | undefined,
|
||||||
|
freq: number
|
||||||
|
): number {
|
||||||
|
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({
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,13 +70,6 @@ export type GeqCurveOptions = {
|
|||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a `draw` callback compatible with LinearGraph's `draw` prop:
|
|
||||||
* ({ ctx, toPx }) => void
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <LinearGraph ... draw={buildGeqCurveDraw({ values, q: 8.65, xMin, xMax, yMin, yMax })} />
|
|
||||||
*/
|
|
||||||
export function buildGeqCurveDraw({
|
export function buildGeqCurveDraw({
|
||||||
values,
|
values,
|
||||||
bandHz = GEQ_BAND_HZ,
|
bandHz = GEQ_BAND_HZ,
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type BiquadCoeffs = { b0: number; b1: number; b2: number; a0: number; a1: number; a2: number };
|
||||||
|
|
||||||
|
// ----------- Gain 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 {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = (A * Math.sin(w0)) / (2 * q);
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: 1 + alpha * A,
|
||||||
|
b1: -2 * cosw0,
|
||||||
|
b2: 1 - alpha * A,
|
||||||
|
a0: 1 + alpha / A,
|
||||||
|
a1: -2 * cosw0,
|
||||||
|
a2: 1 - alpha / A,
|
||||||
|
};
|
||||||
|
|
||||||
|
return biquadMagnitudeDb(freq, fs, coeffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RBJ Audio EQ Cookbook low-shelf biquad, exact magnitude response (dB). */
|
||||||
|
function lowShelfResponseDb(
|
||||||
|
freq: number,
|
||||||
|
f0: number,
|
||||||
|
gainDb: number,
|
||||||
|
q: number,
|
||||||
|
fs: number
|
||||||
|
): number {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = Math.sin(w0) / (2 * q);
|
||||||
|
const sqrtA = Math.sqrt(A);
|
||||||
|
const twoSqrtAAlpha = 2 * sqrtA * alpha;
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: A * (A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha),
|
||||||
|
b1: 2 * A * (A - 1 - (A + 1) * cosw0),
|
||||||
|
b2: A * (A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha),
|
||||||
|
a0: A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha,
|
||||||
|
a1: -2 * (A - 1 + (A + 1) * cosw0),
|
||||||
|
a2: A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha,
|
||||||
|
};
|
||||||
|
|
||||||
|
return biquadMagnitudeDb(freq, fs, coeffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RBJ Audio EQ Cookbook high-shelf biquad, exact magnitude response (dB). */
|
||||||
|
function highShelfResponseDb(
|
||||||
|
freq: number,
|
||||||
|
f0: number,
|
||||||
|
gainDb: number,
|
||||||
|
q: number,
|
||||||
|
fs: number
|
||||||
|
): number {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = Math.sin(w0) / (2 * q);
|
||||||
|
const sqrtA = Math.sqrt(A);
|
||||||
|
const twoSqrtAAlpha = 2 * sqrtA * alpha;
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: A * (A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha),
|
||||||
|
b1: -2 * A * (A - 1 + (A + 1) * cosw0),
|
||||||
|
b2: A * (A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha),
|
||||||
|
a0: A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha,
|
||||||
|
a1: 2 * (A - 1 - (A + 1) * cosw0),
|
||||||
|
a2: A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
band: PEQ,
|
||||||
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
|
): number {
|
||||||
|
if (band.bypass) return 0;
|
||||||
|
|
||||||
|
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(
|
||||||
|
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({
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------- Phase Response -----------
|
||||||
|
|
||||||
|
function peakPhaseDeg(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = (A * Math.sin(w0)) / (2 * q);
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: 1 + alpha * A,
|
||||||
|
b1: -2 * cosw0,
|
||||||
|
b2: 1 - alpha * A,
|
||||||
|
a0: 1 + alpha / A,
|
||||||
|
a1: -2 * cosw0,
|
||||||
|
a2: 1 - alpha / A,
|
||||||
|
};
|
||||||
|
|
||||||
|
return biquadPhaseDeg(freq, fs, coeffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowShelfPhaseDeg(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = Math.sin(w0) / (2 * q);
|
||||||
|
const sqrtA = Math.sqrt(A);
|
||||||
|
const twoSqrtAAlpha = 2 * sqrtA * alpha;
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: A * (A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha),
|
||||||
|
b1: 2 * A * (A - 1 - (A + 1) * cosw0),
|
||||||
|
b2: A * (A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha),
|
||||||
|
a0: A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha,
|
||||||
|
a1: -2 * (A - 1 + (A + 1) * cosw0),
|
||||||
|
a2: A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha,
|
||||||
|
};
|
||||||
|
|
||||||
|
return biquadPhaseDeg(freq, fs, coeffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highShelfPhaseDeg(
|
||||||
|
freq: number,
|
||||||
|
f0: number,
|
||||||
|
gainDb: number,
|
||||||
|
q: number,
|
||||||
|
fs: number
|
||||||
|
): number {
|
||||||
|
if (gainDb === 0) return 0;
|
||||||
|
|
||||||
|
const A = 10 ** (gainDb / 40);
|
||||||
|
const w0 = (2 * Math.PI * f0) / fs;
|
||||||
|
const cosw0 = Math.cos(w0);
|
||||||
|
const alpha = Math.sin(w0) / (2 * q);
|
||||||
|
const sqrtA = Math.sqrt(A);
|
||||||
|
const twoSqrtAAlpha = 2 * sqrtA * alpha;
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: A * (A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha),
|
||||||
|
b1: -2 * A * (A - 1 + (A + 1) * cosw0),
|
||||||
|
b2: A * (A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha),
|
||||||
|
a0: A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha,
|
||||||
|
a1: 2 * (A - 1 - (A + 1) * cosw0),
|
||||||
|
a2: A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const coeffs: BiquadCoeffs = {
|
||||||
|
b0: 1 - alpha,
|
||||||
|
b1: -2 * cosw0,
|
||||||
|
b2: 1 + alpha,
|
||||||
|
a0: 1 + alpha,
|
||||||
|
a1: -2 * cosw0,
|
||||||
|
a2: 1 - alpha,
|
||||||
|
};
|
||||||
|
|
||||||
|
return biquadPhaseDeg(freq, fs, coeffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function peqBandPhaseDeg(
|
||||||
|
freq: number,
|
||||||
|
band: PEQ,
|
||||||
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
|
): number {
|
||||||
|
if (band.bypass) return 0;
|
||||||
|
|
||||||
|
switch (band.filter_type) {
|
||||||
|
case PEQFilter.Peak:
|
||||||
|
return peakPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
|
case PEQFilter.LowShelf:
|
||||||
|
return lowShelfPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
|
case PEQFilter.HighShelf:
|
||||||
|
return highShelfPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
|
||||||
|
|
||||||
|
case PEQFilter.Lp6Db:
|
||||||
|
return lp6PhaseDeg(freq, band.frequency);
|
||||||
|
|
||||||
|
case PEQFilter.Hp6Db:
|
||||||
|
return hp6PhaseDeg(freq, band.frequency);
|
||||||
|
|
||||||
|
case PEQFilter.Lp12Db:
|
||||||
|
return lp12PhaseDeg(freq, band.frequency, band.q);
|
||||||
|
|
||||||
|
case PEQFilter.Hp12Db:
|
||||||
|
return hp12PhaseDeg(freq, band.frequency, band.q);
|
||||||
|
|
||||||
|
case PEQFilter.AllPass1:
|
||||||
|
return allPass1PhaseDeg(freq, band.frequency);
|
||||||
|
|
||||||
|
case PEQFilter.AllPass2:
|
||||||
|
return allPass2PhaseDeg(freq, band.frequency, band.q, sampleRate);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peqTotalPhaseDeg(
|
||||||
|
freq: number,
|
||||||
|
bands: Record<PEQBand, PEQ>,
|
||||||
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
|
): number {
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (const band of Object.values(PEQBand)) {
|
||||||
|
sum += peqBandPhaseDeg(freq, bands[band], sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 buildPeqPhaseCurveDraw({
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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