feat: add peq foor input

This commit is contained in:
2026-08-31 14:12:03 +02:00
parent a0c9fbb674
commit d6d0215e09
14 changed files with 1989 additions and 396 deletions
@@ -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>
+76 -1
View File
@@ -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;
+196
View File
@@ -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),
};
+43 -9
View File
@@ -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,
+2 -182
View File
@@ -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],
};
+99
View File
@@ -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);
}
+189
View File
@@ -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();
};
}
-7
View File
@@ -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,
+567
View File
@@ -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();
};
}
+254
View File
@@ -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();
};
}