From d6d0215e0925263155736e970c1021343d9e7974 Mon Sep 17 00:00:00 2001 From: LucasDLTG Date: Mon, 31 Aug 2026 14:12:03 +0200 Subject: [PATCH] feat: add peq foor input --- .../VerticalSlider/VerticalSlider.tsx | 125 +++- .../components/DSP408Panel/DSP408Panel.tsx | 36 ++ .../components/DelaySection/DelaySection.tsx | 2 +- .../components/InputSection/InputSection.css | 46 +- .../components/InputSection/InputSection.tsx | 551 +++++++++++------ src/features/dsp408/constants.ts | 77 ++- src/features/dsp408/defaults.ts | 196 ++++++ src/features/dsp408/dsp408.ts | 52 +- src/features/dsp408/types.ts | 184 +----- src/features/dsp408/utils/biquad.ts | 99 +++ src/features/dsp408/utils/crossoverCurve.ts | 189 ++++++ src/features/dsp408/utils/geqCurve.ts | 7 - src/features/dsp408/utils/peqCurve.ts | 567 ++++++++++++++++++ src/features/dsp408/utils/totalCurve.ts | 254 ++++++++ 14 files changed, 1989 insertions(+), 396 deletions(-) create mode 100644 src/features/dsp408/defaults.ts create mode 100644 src/features/dsp408/utils/biquad.ts create mode 100644 src/features/dsp408/utils/crossoverCurve.ts create mode 100644 src/features/dsp408/utils/peqCurve.ts create mode 100644 src/features/dsp408/utils/totalCurve.ts diff --git a/src/components/VerticalSlider/VerticalSlider.tsx b/src/components/VerticalSlider/VerticalSlider.tsx index 7f03ec1..893518c 100644 --- a/src/components/VerticalSlider/VerticalSlider.tsx +++ b/src/components/VerticalSlider/VerticalSlider.tsx @@ -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; @@ -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 | 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 (
diff --git a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx index 227adaa..5e90ca2 100644 --- a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx +++ b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx @@ -146,6 +146,42 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) { /> ); + case 'inb': + return ( + + ); + + case 'inc': + return ( + + ); + + case 'ind': + return ( + + ); + case 'matrix': return ( = { [DelayUnit.Millisecond]: 'ms', diff --git a/src/features/dsp408/components/InputSection/InputSection.css b/src/features/dsp408/components/InputSection/InputSection.css index 09b2b90..fdf85b6 100644 --- a/src/features/dsp408/components/InputSection/InputSection.css +++ b/src/features/dsp408/components/InputSection/InputSection.css @@ -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); +} diff --git a/src/features/dsp408/components/InputSection/InputSection.tsx b/src/features/dsp408/components/InputSection/InputSection.tsx index c4154da..2a1e05f 100644 --- a/src/features/dsp408/components/InputSection/InputSection.tsx +++ b/src/features/dsp408/components/InputSection/InputSection.tsx @@ -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 ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} -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; - -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 ( +
+ + + +
+ ); +} 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; + 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) => { + const commit = (overrides: Partial) => { 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 (
-
{'PEQ Parameters'}
+
PEQ Parameters
- {PEQ_SLIDER_PARAMS.map((param) => ( -
- param.step} - unit={param.unit} - value={peq[param.key]} - // onInput={(value) => onLiveValue(channel, param.key, value)} - onChange={(value) => commit({ [param.key]: value } as Partial)} - style={{ width: '100%', flex: '1 1 auto' }} - /> -
{param.label}
-
- ))} +
+ (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' }} + /> + +
Frequency
+
+ +
+ v.toFixed(2)} + value={peq.q} + onChange={(value) => commit({ q: value })} + onInput={(value) => onLiveValue({ ...peq, q: value })} + style={{ width: '100%', flex: '1 1 auto' }} + /> + +
Q
+
+ +
+ 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' }} + /> + +
Gain
+
); @@ -154,7 +205,7 @@ function CrossoverPanel({ const bypass = filter_type === CrossoverFilter.Bypass; const lastFilterType = useRef( - 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) => ( @@ -236,32 +287,29 @@ function CrossoverPanel({ } function PeqBandList({ - channel, - dsp, + bands, + liveBands, activeBand, setActiveBand, setPEQ, }: { - channel: InputChannel; - dsp: DSP; + bands: Record; + liveBands: Partial>; activeBand: PEQBand; setActiveBand: (band: PEQBand) => void; - setPEQ: SetPEQFn; + setPEQ: ( + band: PEQBand, + gainDb: number, + freqHz: number, + q: number, + filterType: PEQFilter, + bypass: boolean + ) => Promise; }) { - const inputState = dsp.state.current_config.input_states[channel]; - - const commit = (band: PEQBand, overrides: Partial) => { - const peq = inputState.peq_chain.bands[band] as PEQ; + const commit = (band: PEQBand, overrides: Partial) => { + 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({
Bypass
- {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}`} + {/* Frequency */} 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, + }); + } + }} /> 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) => ( @@ -356,15 +412,74 @@ type SetFilterFn = ( filterType: CrossoverFilter ) => Promise; -type setPeqBandFn = ( - channel: Channel, - band: PEQBand, - gainDb: number, - freqHz: number, - q: number, - filterType: PEQFilter, - bypass: boolean -) => Promise; +/** 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; +// bands: Record; +// 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 ( +// +// ); +// })} + +//
+// HP +//
+//
+// LP +//
+// +// ); +// } 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; setPeqBypass: (channel: Channel, bypass: boolean) => Promise; }) { // UI variables const [activeBand, setActiveBand] = useState(PEQBand.B1); + const [graphView, setGraphView] = useState('both'); + const [resetting, setResetting] = useState(false); // Live values const [liveHpFreq, setLiveHpFreq] = useState(FREQUENCY_MIN); const [liveLpFreq, setLiveLpFreq] = useState(FREQUENCY_MAX); - const [livePeq, setLivePeq] = useState>>({}); + const [livePeq, setLivePeq] = useState>>({}); + + // 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(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 = { + ...ActivePeq.bands, + ...livePeq, + }; + + const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope }; + const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope }; + return ( <>
-
{activeInputState.name} - Filters
- Math.round(v).toString()} - formatY={(v) => Math.round(v).toString()} - draw={() => {}} - /> +
+
{activeInputState.name} - Filters
+ +
+ + {(graphView === 'both' || graphView === 'magnitude') && ( +
+ 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, + })} + /> +
+ )} + + {(graphView === 'both' || graphView === 'phase') && ( + 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, + })} + /> + )}
@@ -474,27 +674,42 @@ function InputSection({
+ + commitPEQ(activeBand, gain, frequency, q, filter_type, bypass) + } + onLiveValue={(value) => handleLivePEQValue(activeBand, value)} /> - + - + + +
diff --git a/src/features/dsp408/constants.ts b/src/features/dsp408/constants.ts index 32d5b3c..52c5862 100644 --- a/src/features/dsp408/constants.ts +++ b/src/features/dsp408/constants.ts @@ -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.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.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; diff --git a/src/features/dsp408/defaults.ts b/src/features/dsp408/defaults.ts new file mode 100644 index 0000000..1cf934b --- /dev/null +++ b/src/features/dsp408/defaults.ts @@ -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, + 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), +}; diff --git a/src/features/dsp408/dsp408.ts b/src/features/dsp408/dsp408.ts index 6a3d3a4..58b93b8 100644 --- a/src/features/dsp408/dsp408.ts +++ b/src/features/dsp408/dsp408.ts @@ -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; @@ -1325,20 +1325,40 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) { bypass: boolean ): Promise { 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, diff --git a/src/features/dsp408/types.ts b/src/features/dsp408/types.ts index 3754c29..b96cb84 100644 --- a/src/features/dsp408/types.ts +++ b/src/features/dsp408/types.ts @@ -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, - 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.Millisecond]: [0, 680.0], - [DelayUnit.Meter]: [0, 233.58], - [DelayUnit.Feet]: [0, 766.329], -}; diff --git a/src/features/dsp408/utils/biquad.ts b/src/features/dsp408/utils/biquad.ts new file mode 100644 index 0000000..07f7fe4 --- /dev/null +++ b/src/features/dsp408/utils/biquad.ts @@ -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); +} diff --git a/src/features/dsp408/utils/crossoverCurve.ts b/src/features/dsp408/utils/crossoverCurve.ts new file mode 100644 index 0000000..bef36a3 --- /dev/null +++ b/src/features/dsp408/utils/crossoverCurve.ts @@ -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, + { 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(); + }; +} diff --git a/src/features/dsp408/utils/geqCurve.ts b/src/features/dsp408/utils/geqCurve.ts index 7dbed1e..344443b 100644 --- a/src/features/dsp408/utils/geqCurve.ts +++ b/src/features/dsp408/utils/geqCurve.ts @@ -70,13 +70,6 @@ export type GeqCurveOptions = { dimmed?: boolean; }; -/** - * Returns a `draw` callback compatible with LinearGraph's `draw` prop: - * ({ ctx, toPx }) => void - * - * Usage: - * - */ export function buildGeqCurveDraw({ values, bandHz = GEQ_BAND_HZ, diff --git a/src/features/dsp408/utils/peqCurve.ts b/src/features/dsp408/utils/peqCurve.ts new file mode 100644 index 0000000..c22a7f4 --- /dev/null +++ b/src/features/dsp408/utils/peqCurve.ts @@ -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, + 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; + 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, + 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; + 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(); + }; +} diff --git a/src/features/dsp408/utils/totalCurve.ts b/src/features/dsp408/utils/totalCurve.ts new file mode 100644 index 0000000..6b5aede --- /dev/null +++ b/src/features/dsp408/utils/totalCurve.ts @@ -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; + 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; + 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, + highPass: CrossoverSpec | undefined, + lowPass: CrossoverSpec | undefined, + sampleRate: number +): number { + return ( + peqTotalCurveDb(freq, bands, sampleRate) + crossoverPairMagnitudeDb(highPass, lowPass, freq) + ); +} + +function totalPhaseDeg( + freq: number, + bands: Record, + 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(); + }; +}