feat: add peq foor input
This commit is contained in:
@@ -10,17 +10,19 @@ type VerticalSliderProps = {
|
||||
max: number;
|
||||
|
||||
step?: StepFn;
|
||||
quantize?: QuantizeFn;
|
||||
|
||||
/**
|
||||
* Optional hard snap function applied after every value change (drag,
|
||||
* keyboard, wheel, or typed input) — takes precedence over the built-in
|
||||
* step-based rounding. Use this when the underlying hardware/DSP only
|
||||
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw
|
||||
* index mapped through a log curve) and the slider must always land on
|
||||
* one of those exact values rather than an interpolated approximation.
|
||||
* See makeLogQuantize().
|
||||
* Optional discrete value set (must be sorted ascending). When provided,
|
||||
* this takes full precedence over `step`/`quantize`/`scale`: the slider
|
||||
* only ever lands on entries from this array. `min`/`max` are used to
|
||||
* slice out the applicable sub-range of `values` (e.g. per-filter-type
|
||||
* Q ranges that all share one master Q table) — both bounds should be
|
||||
* exact members of `values`. Thumb position and drag/keyboard/wheel
|
||||
* 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;
|
||||
onChange?: (value: number) => Promise<boolean>;
|
||||
@@ -31,7 +33,7 @@ type VerticalSliderProps = {
|
||||
|
||||
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;
|
||||
|
||||
className?: string;
|
||||
@@ -60,8 +62,36 @@ function safeLogBound(n: number) {
|
||||
return n > 0 ? n : Number.EPSILON;
|
||||
}
|
||||
|
||||
/** Maps a real value to a 0..1 position, honoring the chosen scale. */
|
||||
function valueToPercent(v: number, min: number, max: number, scale: Scale) {
|
||||
/** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
|
||||
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') {
|
||||
const logMin = Math.log(safeLogBound(min));
|
||||
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);
|
||||
}
|
||||
|
||||
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */
|
||||
function percentToValue(percent: number, min: number, max: number, scale: 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,
|
||||
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') {
|
||||
const logMin = Math.log(safeLogBound(min));
|
||||
const logMax = Math.log(safeLogBound(max));
|
||||
@@ -126,6 +166,7 @@ export default function VerticalSlider({
|
||||
max,
|
||||
step = defaultStep,
|
||||
quantize,
|
||||
values,
|
||||
value,
|
||||
onChange,
|
||||
onInput,
|
||||
@@ -151,13 +192,21 @@ export default function VerticalSlider({
|
||||
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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
|
||||
console.warn(
|
||||
'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(() => {
|
||||
if (
|
||||
dragging.current ||
|
||||
@@ -191,10 +240,10 @@ export default function VerticalSlider({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const decimals = useMemo(
|
||||
() => Math.min(3, getDecimals(step(displayValue))),
|
||||
[step, displayValue]
|
||||
);
|
||||
const decimals = useMemo(() => {
|
||||
if (listValues) return Math.min(3, getDecimals(displayValue));
|
||||
return Math.min(3, getDecimals(step(displayValue)));
|
||||
}, [listValues, step, displayValue]);
|
||||
|
||||
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
|
||||
|
||||
@@ -203,6 +252,10 @@ export default function VerticalSlider({
|
||||
function normalizeValue(input: number) {
|
||||
const clamped = Math.min(max, Math.max(min, input));
|
||||
|
||||
if (listValues) {
|
||||
return listValues[closestIndex(listValues, clamped)];
|
||||
}
|
||||
|
||||
if (quantize) {
|
||||
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
|
||||
// key presses appear to snap back to the pre-press position.
|
||||
const base = keyboardValue.current ?? displayValue;
|
||||
const currentStep = step(base);
|
||||
|
||||
let next = base;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
|
||||
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 === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
|
||||
next = normalizeValue(next);
|
||||
}
|
||||
|
||||
next = normalizeValue(next);
|
||||
keyboardValue.current = next;
|
||||
updateValue(next);
|
||||
}
|
||||
@@ -265,12 +328,20 @@ export default function VerticalSlider({
|
||||
wheelEditing.current = true;
|
||||
|
||||
const base = wheelValue.current ?? displayValue;
|
||||
const currentStep = step(base);
|
||||
// deltaY < 0 means scrolling up/away from the user — treat that as
|
||||
// increasing the value, matching the vertical slider's "up = more".
|
||||
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;
|
||||
updateValue(next);
|
||||
|
||||
@@ -317,7 +388,7 @@ export default function VerticalSlider({
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -378,7 +449,7 @@ export default function VerticalSlider({
|
||||
setEditingText(null);
|
||||
}
|
||||
|
||||
const percent = valueToPercent(displayValue, min, max, scale);
|
||||
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
||||
|
||||
return (
|
||||
<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':
|
||||
return (
|
||||
<MatrixSection
|
||||
|
||||
@@ -6,7 +6,7 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import { useMemo, useState } from 'react';
|
||||
import './DelaySection.css';
|
||||
import { DELAY_RANGES } from '../../types';
|
||||
import { DELAY_RANGES } from '../../constants';
|
||||
|
||||
const UNIT_LABEL: Record<DelayUnit, string> = {
|
||||
[DelayUnit.Millisecond]: 'ms',
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
position: relative;
|
||||
height: 10;
|
||||
|
||||
aspect-ratio: 0.75;
|
||||
aspect-ratio: 0.8;
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
@@ -361,3 +361,47 @@
|
||||
|
||||
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,
|
||||
makeLogQuantize,
|
||||
} from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
|
||||
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
|
||||
// if the actual enum differs.
|
||||
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter, Crossover } from '../../types';
|
||||
import {
|
||||
CROSSOVER_FILTER_LABEL,
|
||||
PEQ_BANDS_ORDER,
|
||||
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 { CrossoverFilter } from '../../types';
|
||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import { useRef, useState } from 'react';
|
||||
import './InputSection.css';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import MeterBar from '../../../../components/MeterBar/MeterBar';
|
||||
import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
||||
import {
|
||||
FREQUENCY_MAX,
|
||||
FREQUENCY_MIN,
|
||||
@@ -25,114 +29,161 @@ import {
|
||||
GRAPH_Y_MIN,
|
||||
} from '../../constants';
|
||||
|
||||
const PEQ_BANDS: PEQBand[] = [
|
||||
PEQBand.B1,
|
||||
PEQBand.B2,
|
||||
PEQBand.B3,
|
||||
PEQBand.B4,
|
||||
PEQBand.B5,
|
||||
PEQBand.B6,
|
||||
PEQBand.B7,
|
||||
PEQBand.B8,
|
||||
];
|
||||
type GraphView = 'both' | 'magnitude' | 'phase';
|
||||
|
||||
// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types
|
||||
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [
|
||||
{ value: PEQFilter.Peak, label: 'Peak' },
|
||||
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
|
||||
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
|
||||
];
|
||||
function GraphViewToggle({
|
||||
view,
|
||||
onChange,
|
||||
}: {
|
||||
view: GraphView;
|
||||
onChange: (v: GraphView) => void;
|
||||
}) {
|
||||
const options: { value: GraphView; label: string }[] = [
|
||||
{ value: 'both', label: 'Both' },
|
||||
{ value: 'magnitude', label: 'Mag' },
|
||||
{ value: 'phase', label: 'Phase' },
|
||||
];
|
||||
|
||||
const FILTER_TYPE_OPTIONS: { 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' },
|
||||
];
|
||||
return (
|
||||
<div className="input-graph-view-toggle">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PEQ_SLIDER_PARAMS = [
|
||||
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
|
||||
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
|
||||
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
|
||||
] as const;
|
||||
|
||||
type SetPEQFn = (
|
||||
channel: Channel,
|
||||
band: PEQBand,
|
||||
gainDb: number,
|
||||
freqHz: number,
|
||||
q: number,
|
||||
filterType: PEQFilter,
|
||||
bypass: boolean
|
||||
) => Promise<boolean>;
|
||||
|
||||
type PeqParamKey = 'frequency' | 'q' | 'gain';
|
||||
|
||||
type PEQValue = {
|
||||
frequency: number;
|
||||
q: number;
|
||||
gain: number;
|
||||
filter_type: PEQFilter;
|
||||
function InputActions({
|
||||
bypass,
|
||||
resetting,
|
||||
onBypass,
|
||||
onReset,
|
||||
}: {
|
||||
bypass: boolean;
|
||||
};
|
||||
resetting: boolean;
|
||||
onBypass: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant="toggle"
|
||||
onClick={onReset}
|
||||
disabled={resetting}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: '3.5cqh',
|
||||
paddingTop: '2cqh',
|
||||
paddingBottom: '2cqh',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{resetting ? 'Resetting…' : 'Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeqPanelSliders({
|
||||
channel,
|
||||
dsp,
|
||||
band,
|
||||
peq,
|
||||
setPEQ,
|
||||
// onLiveValue,
|
||||
onLiveValue,
|
||||
}: {
|
||||
channel: InputChannel;
|
||||
dsp: DSP;
|
||||
band: PEQBand;
|
||||
setPEQ: SetPEQFn;
|
||||
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
|
||||
peq: PEQ;
|
||||
setPEQ: (
|
||||
gainDb: number,
|
||||
freqHz: number,
|
||||
q: number,
|
||||
filterType: PEQFilter,
|
||||
bypass: boolean
|
||||
) => Promise<boolean>;
|
||||
onLiveValue: (value: PEQ) => void;
|
||||
}) {
|
||||
const inputState = dsp.state.current_config.input_states[channel];
|
||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
||||
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||
|
||||
const commit = (overrides: Partial<PEQValue>) => {
|
||||
const commit = (overrides: Partial<PEQ>) => {
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(
|
||||
{ Input: channel },
|
||||
band,
|
||||
next.gain,
|
||||
next.frequency,
|
||||
next.q,
|
||||
next.filter_type,
|
||||
next.bypass
|
||||
);
|
||||
return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{PEQ_SLIDER_PARAMS.map((param) => (
|
||||
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
|
||||
<VerticalSlider
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={() => param.step}
|
||||
unit={param.unit}
|
||||
value={peq[param.key]}
|
||||
// onInput={(value) => onLiveValue(channel, param.key, value)}
|
||||
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValue>)}
|
||||
style={{ width: '100%', flex: '1 1 auto' }}
|
||||
/>
|
||||
<div className="input-panel__cell-label">{param.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="input-panel__cell input-panel__cell--frequency">
|
||||
<VerticalSlider
|
||||
min={FREQUENCY_MIN}
|
||||
max={FREQUENCY_MAX}
|
||||
scale="log"
|
||||
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
|
||||
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
|
||||
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
|
||||
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' }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
@@ -154,7 +205,7 @@ function CrossoverPanel({
|
||||
const bypass = filter_type === CrossoverFilter.Bypass;
|
||||
|
||||
const lastFilterType = useRef<CrossoverFilter>(
|
||||
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk24
|
||||
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
|
||||
);
|
||||
|
||||
const commit = async (overrides: {
|
||||
@@ -196,7 +247,7 @@ function CrossoverPanel({
|
||||
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
|
||||
value={frequency}
|
||||
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 })}
|
||||
onInput={(value) => onLiveValue(value)}
|
||||
@@ -208,7 +259,7 @@ function CrossoverPanel({
|
||||
value={bypass ? lastFilterType.current : filter_type}
|
||||
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}>
|
||||
{opt.label}
|
||||
</option>
|
||||
@@ -236,32 +287,29 @@ function CrossoverPanel({
|
||||
}
|
||||
|
||||
function PeqBandList({
|
||||
channel,
|
||||
dsp,
|
||||
bands,
|
||||
liveBands,
|
||||
activeBand,
|
||||
setActiveBand,
|
||||
setPEQ,
|
||||
}: {
|
||||
channel: InputChannel;
|
||||
dsp: DSP;
|
||||
bands: Record<PEQBand, PEQ>;
|
||||
liveBands: Partial<Record<PEQBand, PEQ>>;
|
||||
activeBand: PEQBand;
|
||||
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<PEQValue>) => {
|
||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
||||
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
|
||||
const peq = liveBands[band] ?? bands[band];
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(
|
||||
{ Input: channel },
|
||||
band,
|
||||
next.gain,
|
||||
next.frequency,
|
||||
next.q,
|
||||
next.filter_type,
|
||||
next.bypass
|
||||
);
|
||||
return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -275,8 +323,8 @@ function PeqBandList({
|
||||
<div className="peq-band-list__cell">Bypass</div>
|
||||
</div>
|
||||
|
||||
{PEQ_BANDS.map((band, i) => {
|
||||
const peq = inputState.peq_chain.bands[band] as PEQ;
|
||||
{PEQ_BANDS_ORDER.map((band, i) => {
|
||||
const peq = liveBands[band] ?? bands[band];
|
||||
const isActive = band === activeBand;
|
||||
|
||||
return (
|
||||
@@ -293,14 +341,22 @@ function PeqBandList({
|
||||
{`B${i + 1}`}
|
||||
</Button>
|
||||
|
||||
{/* Frequency */}
|
||||
<input
|
||||
className="peq-band-list__input"
|
||||
type="number"
|
||||
step={0.5}
|
||||
min={19.7}
|
||||
max={20160}
|
||||
value={peq.frequency}
|
||||
onChange={(e) => commit(band, { frequency: Number(e.target.value) })}
|
||||
type="text"
|
||||
value={
|
||||
peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
commit(band, {
|
||||
frequency: peq.frequency >= 1000 ? value * 1000 : value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -326,9 +382,9 @@ function PeqBandList({
|
||||
<select
|
||||
className="peq-band-list__select"
|
||||
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}>
|
||||
{opt.label}
|
||||
</option>
|
||||
@@ -356,15 +412,74 @@ type SetFilterFn = (
|
||||
filterType: CrossoverFilter
|
||||
) => Promise<boolean>;
|
||||
|
||||
type setPeqBandFn = (
|
||||
channel: Channel,
|
||||
band: PEQBand,
|
||||
gainDb: number,
|
||||
freqHz: number,
|
||||
q: number,
|
||||
filterType: PEQFilter,
|
||||
bypass: boolean
|
||||
) => Promise<boolean>;
|
||||
/** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
|
||||
// function GraphMarkers({
|
||||
// graphRef,
|
||||
// bands,
|
||||
// activeBand,
|
||||
// setActiveBand,
|
||||
// hpFreq,
|
||||
// hpBypassed,
|
||||
// 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({
|
||||
dsp,
|
||||
@@ -378,16 +493,31 @@ function InputSection({
|
||||
channel: InputChannel;
|
||||
setHighPass: 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>;
|
||||
}) {
|
||||
// UI variables
|
||||
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
||||
const [graphView, setGraphView] = useState<GraphView>('both');
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
// Live values
|
||||
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
||||
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
|
||||
const activeInputState = dsp.state.current_config.input_states[channel];
|
||||
@@ -395,6 +525,24 @@ function InputSection({
|
||||
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
|
||||
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
|
||||
const handleLiveHighPassValue = (value: number) => {
|
||||
setLiveHpFreq(value);
|
||||
@@ -404,7 +552,7 @@ function InputSection({
|
||||
setLiveLpFreq(value);
|
||||
};
|
||||
|
||||
const handleLivePEQValue = (band: PEQBand, value: PEQValue) => {
|
||||
const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
|
||||
setLivePeq((prev) => ({
|
||||
...prev,
|
||||
[band]: value,
|
||||
@@ -439,34 +587,86 @@ function InputSection({
|
||||
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
|
||||
if (success) {
|
||||
setLivePeq((prev) => {
|
||||
const channelLive = prev[band];
|
||||
if (!channelLive || !(band in channelLive)) return prev;
|
||||
const next = { ...channelLive };
|
||||
return { ...prev, [channel]: next };
|
||||
const next = { ...prev };
|
||||
delete next[band];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className="input-graph-section">
|
||||
<div className="input-graph-panel">
|
||||
<div className="input-graph__title">{activeInputState.name} - Filters</div>
|
||||
<LinearGraph
|
||||
xMin={GRAPH_X_MIN}
|
||||
xMax={GRAPH_X_MAX}
|
||||
xScale="log"
|
||||
yMin={GRAPH_Y_MIN}
|
||||
yMax={GRAPH_Y_MAX}
|
||||
yStep={3}
|
||||
xLabel="Hz"
|
||||
yLabel="dB"
|
||||
showGrid={true}
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => Math.round(v).toString()}
|
||||
draw={() => {}}
|
||||
/>
|
||||
<div className="input-graph__title-row">
|
||||
<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
|
||||
ref={magnitudeGraphRef}
|
||||
xMin={GRAPH_X_MIN}
|
||||
xMax={GRAPH_X_MAX}
|
||||
xScale="log"
|
||||
yMin={GRAPH_Y_MIN}
|
||||
yMax={GRAPH_Y_MAX}
|
||||
yStep={3}
|
||||
xLabel="Hz"
|
||||
yLabel="dB"
|
||||
showGrid={true}
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => Math.round(v).toString()}
|
||||
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>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
|
||||
@@ -474,27 +674,42 @@ function InputSection({
|
||||
<div className="input-control-panel">
|
||||
<ChannelGroup title="PEQ">
|
||||
<PeqBandList
|
||||
channel={channel}
|
||||
dsp={dsp}
|
||||
bands={ActivePeq.bands}
|
||||
liveBands={livePeq}
|
||||
activeBand={activeBand}
|
||||
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 title="CrossOver">
|
||||
<CrossoverPanel
|
||||
crossover={ActiveHighPassFilter}
|
||||
title="HighPass"
|
||||
setCrossover={commitHighPass}
|
||||
onLiveValue={handleLiveHighPassValue}
|
||||
/>
|
||||
<CrossoverPanel
|
||||
crossover={ActiveLowPassFilter}
|
||||
title="LowPass"
|
||||
setCrossover={commitLowPass}
|
||||
onLiveValue={handleLiveLowPassValue}
|
||||
/>
|
||||
<CrossoverPanel
|
||||
crossover={ActiveHighPassFilter}
|
||||
title="HighPass"
|
||||
setCrossover={commitHighPass}
|
||||
onLiveValue={handleLiveHighPassValue}
|
||||
</ChannelGroup>
|
||||
|
||||
<ChannelGroup title="Actions">
|
||||
<InputActions
|
||||
bypass={ActivePeq.bypass}
|
||||
resetting={resetting}
|
||||
onBypass={handleBypassClick}
|
||||
onReset={handleResetPeq}
|
||||
/>
|
||||
</ChannelGroup>
|
||||
</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[] = [
|
||||
InputChannel.InA,
|
||||
@@ -94,6 +102,66 @@ export const GEQ_GAIN_MAX = 12;
|
||||
export const GEQ_GAIN_STEP = 0.1;
|
||||
|
||||
// ------ 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_MAX = 20160;
|
||||
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_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 ------
|
||||
export const GRAPH_X_MIN = 10;
|
||||
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';
|
||||
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
|
||||
import { registerPollStopper } from '../../dsp/pollRegistry';
|
||||
import { defaultDSPState, defaultMeters } from './types';
|
||||
import { DELAY_RANGES } from './types';
|
||||
import { defaultDSPState, defaultMeters } from './defaults';
|
||||
import { DELAY_RANGES, PEQ_Q_RANGES } from './constants';
|
||||
|
||||
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
|
||||
|
||||
@@ -1325,20 +1325,40 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
||||
bypass: boolean
|
||||
): Promise<boolean> {
|
||||
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) => {
|
||||
if (!d.state) return d;
|
||||
|
||||
const cfg = d.state.current_config;
|
||||
const peqBand = {
|
||||
gain: gainDb,
|
||||
frequency: freqHz,
|
||||
q,
|
||||
filter_type: filterType,
|
||||
bypass,
|
||||
};
|
||||
|
||||
if ('Input' in channel) {
|
||||
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 {
|
||||
...d,
|
||||
@@ -1365,6 +1385,20 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
||||
}
|
||||
|
||||
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 {
|
||||
...d,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FREQUENCY_MAX, FREQUENCY_MIN } from './constants';
|
||||
|
||||
export enum DelayUnit {
|
||||
Millisecond = 'Millisecond',
|
||||
Meter = 'Meter',
|
||||
@@ -247,185 +249,3 @@ export interface Meter {
|
||||
export interface Meters {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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({
|
||||
values,
|
||||
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