Compare commits

..
5 Commits
Author SHA1 Message Date
LucasDLTG 81a99b7173 feat: update 2026-09-06 12:45:27 +02:00
LucasDLTG 75ac349790 refactor: graph to use a set of points instead of taking the whole dsp oobject 2026-08-31 20:14:07 +02:00
LucasDLTG 21728b5e6d fix: eslint errors 2026-08-31 14:12:36 +02:00
LucasDLTG d6d0215e09 feat: add peq foor input 2026-08-31 14:12:03 +02:00
LucasDLTG a0c9fbb674 feat: add behaviour to hp/lp filters 2026-08-13 11:32:06 +02:00
36 changed files with 5731 additions and 4614 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "thomann-dsp-app",
"width": 1200, "width": 1600,
"height": 800, "height": 950,
"minWidth": 1200, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
+100 -27
View File
@@ -10,28 +10,30 @@ type VerticalSliderProps = {
max: number; max: number;
step?: StepFn; step?: StepFn;
quantize?: QuantizeFn;
/** /**
* Optional hard snap function applied after every value change (drag, * Optional discrete value set (must be sorted ascending). When provided,
* keyboard, wheel, or typed input) — takes precedence over the built-in * this takes full precedence over `step`/`quantize`/`scale`: the slider
* step-based rounding. Use this when the underlying hardware/DSP only * only ever lands on entries from this array. `min`/`max` are used to
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw * slice out the applicable sub-range of `values` (e.g. per-filter-type
* index mapped through a log curve) and the slider must always land on * Q ranges that all share one master Q table) — both bounds should be
* one of those exact values rather than an interpolated approximation. * exact members of `values`. Thumb position and drag/keyboard/wheel
* See makeLogQuantize(). * navigation are index-based rather than value-based, since spacing in
* the array need not be uniform or log-uniform.
*/ */
quantize?: QuantizeFn; values?: readonly number[];
value: number; value: number;
onChange?: (value: number) => Promise<boolean>; onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void; onInput?: (value: number) => void;
unit?: string; unit?: string | ((value: number) => string);
formatValue?: (value: number, decimals: number) => string; formatValue?: (value: number, decimals: number) => string;
showTicks?: boolean; showTicks?: boolean;
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */ /** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
scale?: Scale; scale?: Scale;
className?: string; className?: string;
@@ -60,8 +62,36 @@ function safeLogBound(n: number) {
return n > 0 ? n : Number.EPSILON; return n > 0 ? n : Number.EPSILON;
} }
/** Maps a real value to a 0..1 position, honoring the chosen scale. */ /** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
function valueToPercent(v: number, min: number, max: number, scale: Scale) { function closestIndex(arr: readonly number[], v: number) {
let lo = 0;
let hi = arr.length - 1;
if (v <= arr[0]) return 0;
if (v >= arr[hi]) return hi;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] === v) return mid;
if (arr[mid] < v) lo = mid + 1;
else hi = mid;
}
const a = arr[lo - 1];
const b = arr[lo];
return v - a <= b - v ? lo - 1 : lo;
}
/** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */
function valueToPercent(
v: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
return closestIndex(list, v) / (list.length - 1);
}
if (scale === 'log') { if (scale === 'log') {
const logMin = Math.log(safeLogBound(min)); const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max)); const logMax = Math.log(safeLogBound(max));
@@ -73,8 +103,18 @@ function valueToPercent(v: number, min: number, max: number, scale: Scale) {
return (v - min) / (max - min); return (v - min) / (max - min);
} }
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */ /** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */
function percentToValue(percent: number, min: number, max: number, scale: Scale) { function percentToValue(
percent: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
const idx = Math.round(percent * (list.length - 1));
return list[Math.min(list.length - 1, Math.max(0, idx))];
}
if (scale === 'log') { if (scale === 'log') {
const logMin = Math.log(safeLogBound(min)); const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max)); const logMax = Math.log(safeLogBound(max));
@@ -126,6 +166,7 @@ export default function VerticalSlider({
max, max,
step = defaultStep, step = defaultStep,
quantize, quantize,
values,
value, value,
onChange, onChange,
onInput, onInput,
@@ -151,13 +192,21 @@ export default function VerticalSlider({
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [tickCount, setTickCount] = useState(MIN_TICKS); const [tickCount, setTickCount] = useState(MIN_TICKS);
if (scale === 'log' && (min <= 0 || max <= 0)) { if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.' 'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
); );
} }
// Slice the discrete value set down to the applicable [min, max] sub-range.
// Falls back to the full array if the slice is empty (e.g. misconfigured bounds).
const listValues = useMemo(() => {
if (!values || values.length === 0) return null;
const filtered = values.filter((v) => v >= min && v <= max);
return filtered.length > 0 ? filtered : values;
}, [values, min, max]);
useEffect(() => { useEffect(() => {
if ( if (
dragging.current || dragging.current ||
@@ -191,16 +240,22 @@ export default function VerticalSlider({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const decimals = useMemo( const decimals = useMemo(() => {
() => Math.min(3, getDecimals(step(displayValue))), if (listValues) return Math.min(3, getDecimals(displayValue));
[step, displayValue] return Math.min(3, getDecimals(step(displayValue)));
); }, [listValues, step, displayValue]);
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals); const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
const displayUnit = typeof unit === 'function' ? unit(displayValue) : unit;
function normalizeValue(input: number) { function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input)); const clamped = Math.min(max, Math.max(min, input));
if (listValues) {
return listValues[closestIndex(listValues, clamped)];
}
if (quantize) { if (quantize) {
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6)); return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
} }
@@ -230,13 +285,23 @@ export default function VerticalSlider({
// hasn't landed yet, `value` can still be stale, which made repeated // hasn't landed yet, `value` can still be stale, which made repeated
// key presses appear to snap back to the pre-press position. // key presses appear to snap back to the pre-press position.
const base = keyboardValue.current ?? displayValue; const base = keyboardValue.current ?? displayValue;
const currentStep = step(base);
let next = base; let next: number;
if (listValues) {
const idx = closestIndex(listValues, base);
let nextIdx = idx;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight')
nextIdx = Math.min(listValues.length - 1, idx + 1);
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1);
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep; if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep; if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next); next = normalizeValue(next);
}
keyboardValue.current = next; keyboardValue.current = next;
updateValue(next); updateValue(next);
} }
@@ -263,12 +328,20 @@ export default function VerticalSlider({
wheelEditing.current = true; wheelEditing.current = true;
const base = wheelValue.current ?? displayValue; const base = wheelValue.current ?? displayValue;
const currentStep = step(base);
// deltaY < 0 means scrolling up/away from the user — treat that as // deltaY < 0 means scrolling up/away from the user — treat that as
// increasing the value, matching the vertical slider's "up = more". // increasing the value, matching the vertical slider's "up = more".
const direction = e.deltaY < 0 ? 1 : -1; const direction = e.deltaY < 0 ? 1 : -1;
const next = normalizeValue(base + direction * currentStep); let next: number;
if (listValues) {
const idx = closestIndex(listValues, base);
const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction));
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = normalizeValue(base + direction * currentStep);
}
wheelValue.current = next; wheelValue.current = next;
updateValue(next); updateValue(next);
@@ -315,7 +388,7 @@ export default function VerticalSlider({
if (!sliderRef.current) return; if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect(); const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height; const percent = 1 - (clientY - rect.top) / rect.height;
const raw = percentToValue(percent, min, max, scale); const raw = percentToValue(percent, min, max, scale, listValues);
updateValue(raw); updateValue(raw);
} }
@@ -376,7 +449,7 @@ export default function VerticalSlider({
setEditingText(null); setEditingText(null);
} }
const percent = valueToPercent(displayValue, min, max, scale); const percent = valueToPercent(displayValue, min, max, scale, listValues);
return ( return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}> <div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
@@ -434,7 +507,7 @@ export default function VerticalSlider({
} }
}} }}
/> />
{unit && <span className="value-unit">{unit}</span>} {displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
</div> </div>
</div> </div>
); );
-264
View File
@@ -1,264 +0,0 @@
.topbar {
display: flex;
align-items: center;
height: var(--header-height);
background: var(--bg-void);
border-bottom: 1px solid var(--border-hairline);
-webkit-app-region: drag;
padding-inline: var(--space-3);
gap: var(--space-3);
box-sizing: border-box;
}
.logo-slot {
display: flex;
height: 85%;
aspect-ratio: 1 / 1;
padding: 0;
border: none;
background: transparent;
overflow: hidden;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: var(--radius-pill);
}
.tabstrip {
display: flex;
height: var(--header-height);
flex: 1 1 auto;
overflow-x: auto;
overflow-y: hidden;
min-width: 0;
-webkit-app-region: no-drag;
border-left: 1px solid var(--border-hairline);
scrollbar-width: thin;
scrollbar-color: var(--border-hairline) transparent;
scroll-behavior: smooth;
scroll-padding-inline: var(--space-3);
scroll-snap-type: x proximity;
align-items: center;
gap: var(--space-4);
padding-left: var(--space-4);
container-type: inline-size;
container-name: tabstrip;
-webkit-mask-image: linear-gradient(to right, transparent, black 0);
mask-image: linear-gradient(to right, transparent, black 0);
}
.tabstrip-actions {
display: flex;
align-items: center;
justify-content: center;
height: var(--header-height);
flex: 0 0 auto;
gap: var(--space-3);
}
.tab-action {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
font: inherit;
background: transparent;
border: var(--border-width-active) solid transparent;
}
.tab-action:hover {
background: var(--bg-raised);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.tab-add {
height: 70%;
border-radius: 50%;
color: var(--text-muted);
aspect-ratio: 1;
}
.crown-action {
height: 70%;
aspect-ratio: 1;
border-radius: 50%;
color: #facc15;
width: auto;
}
.crown-action:hover {
color: #fde047;
}
.icon-crown,
.icon-tab-add,
.icon-tab-add-empty {
aspect-ratio: 1/1;
height: 80%;
}
.tab-add-empty {
height: 70%;
border-radius: var(--radius-pill);
border: var(--border-width-active) dashed var(--border-hairline);
color: var(--text-muted);
font-family: var(--font-ui);
font-weight: var(--font-weight-medium);
flex-shrink: 0;
gap: var(--space-2);
padding-inline: var(--space-2);
}
.tab-add-empty:hover {
border-color: var(--accent-brand);
border-style: solid;
}
/* Tab */
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
height: var(--tab-height);
padding: 0 var(--space-3);
border: var(--border-width-active) solid var(--border-hairline);
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--font-xs);
cursor: pointer;
box-sizing: border-box;
scroll-snap-align: start;
flex: 0 0 25%;
max-width: 25%;
min-width: calc(
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size)
);
}
.tab:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.tab-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
}
.tab-led {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0;
}
.tab-close {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
margin-left: auto;
padding: 0;
font-size: var(--tab-close-font);
color: var(--text-muted);
background: transparent;
}
.tab--active {
background: var(--bg-panel);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.tab--dimmed {
opacity: 0.55;
}
.tab-close:hover {
background: var(--bg-panel);
color: var(--text-primary);
}
.tabstrip[data-fade-start='true'] {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
black
);
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
}
.tabstrip[data-fade-end='true'] {
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
}
.tabstrip[data-fade-start='true'][data-fade-end='true'] {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
}
.tab[data-step='6'],
.tab[data-step='8'],
.tab[data-step='10'] {
min-width: calc(
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size)
);
}
-222
View File
@@ -1,222 +0,0 @@
import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon';
import Led from '../Led/Led';
import { CloseIcon } from '../../assets/icons/CloseIcon';
import './Topbar.css';
function getStep(count: number) {
if (count <= 4) return 4;
if (count <= 6) return 6;
if (count <= 8) return 8;
return 10;
}
function Tab({
dsp,
active,
dimmed,
step,
onSelect,
onRemove,
onConnect,
onDisconnect,
}: {
dsp: DSP;
active: boolean;
dimmed: boolean;
step: number;
onSelect: () => void;
onRemove: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const isConnected = dsp.status === 'connected';
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
};
return (
<div
className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
.filter(Boolean)
.join(' ')}
data-step={step}
tabIndex={0}
role="tab"
aria-selected={active}
onClick={onSelect}
onKeyDown={handleTabKeyDown}
>
<button
type="button"
className="tab-led"
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
if (isConnected) {
onDisconnect(dsp);
} else {
onConnect(dsp);
}
}}
>
<Led status={dsp.status} />
</button>
<span className="tab-name" title={dsp.name}>
{dsp.name}
</span>
<button
type="button"
className="tab-close"
aria-label={`Remove ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<CloseIcon />
</button>
</div>
);
}
function TopBar({
dsps,
selected,
page,
setPage,
setSelect,
onSelect,
onRemove,
onAddClick,
onConnect,
onDisconnect,
}: {
dsps: DSP[];
selected: number | null;
page: AppPage;
setPage: (page: AppPage) => void;
setSelect: (select: number | null) => void;
onSelect: (id: number) => void;
onRemove: (dsp: DSP) => void;
onAddClick: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const hasTabs = dsps.length > 0;
const tabstripRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(dsps.length);
const step = getStep(dsps.length);
const updateFade = () => {
const el = tabstripRef.current;
if (!el) return;
const atStart = el.scrollLeft <= 1;
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
el.dataset.fadeStart = String(!atStart);
el.dataset.fadeEnd = String(!atEnd && el.scrollWidth > el.clientWidth);
};
useEffect(() => {
const el = tabstripRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
el.scrollLeft += e.deltaY;
e.preventDefault();
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
useEffect(() => {
updateFade();
const el = tabstripRef.current;
el?.addEventListener('scroll', updateFade);
window.addEventListener('resize', updateFade);
return () => {
el?.removeEventListener('scroll', updateFade);
window.removeEventListener('resize', updateFade);
};
}, [dsps.length]);
useEffect(() => {
if (dsps.length > prevCount.current && tabstripRef.current) {
tabstripRef.current.scrollTo({
left: tabstripRef.current.scrollWidth,
behavior: 'smooth',
});
}
prevCount.current = dsps.length;
}, [dsps.length]);
return (
<header className="topbar">
<button
className="logo-slot"
onClick={() => {
setSelect(null);
setPage('home');
}}
aria-label="Go to home"
>
<AnimatedLogo />
</button>
<div className="tabstrip" ref={tabstripRef}>
{dsps.map((dsp) => (
<Tab
key={dsp.id}
dsp={dsp}
step={step}
active={selected === dsp.id && !(page == 'add-dsp')}
dimmed={page === 'add-dsp'}
onSelect={() => onSelect(dsp.id)}
onRemove={() => onRemove(dsp)}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
))}
</div>
<div className="tabstrip-actions">
{hasTabs ? (
<button className="tab-add tab-action" aria-label="Add DSP" onClick={onAddClick}>
<PlusIcon className="icon-tab-add" />
</button>
) : (
<button className="tab-add-empty tab-action" onClick={onAddClick}>
<PlusIcon className="icon-tab-add-empty" />
<span>Add DSP</span>
</button>
)}
<button
className="crown-action tab-action"
title="Credits & Support"
aria-label="Credits & Support"
onClick={() => {
setPage('credits');
setSelect(null);
}}
>
<CrownIcon className="icon-crown" />
</button>
</div>
</header>
);
}
export default TopBar;
@@ -146,6 +146,42 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
/> />
); );
case 'inb':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InB}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'inc':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InC}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'ind':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InD}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'matrix': case 'matrix':
return ( return (
<MatrixSection <MatrixSection
@@ -6,7 +6,7 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import './DelaySection.css'; import './DelaySection.css';
import { DELAY_RANGES } from '../../types'; import { DELAY_RANGES } from '../../constants';
const UNIT_LABEL: Record<DelayUnit, string> = { const UNIT_LABEL: Record<DelayUnit, string> = {
[DelayUnit.Millisecond]: 'ms', [DelayUnit.Millisecond]: 'ms',
@@ -103,3 +103,52 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.gain-graph-title-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1cqh;
padding: 1cqh 1.5cqh;
flex-wrap: wrap;
}
.gain-graph-title-row .gain-graph-title {
padding: 0;
}
.magnitude-selector {
display: flex;
flex-wrap: wrap;
gap: 0.5cqh;
}
.magnitude-selector__chip {
display: flex;
align-items: center;
gap: 0.6cqh;
font-family: var(--font-ui);
font-size: 1.8cqh;
padding: 0.4cqh 1cqh;
background: var(--bg-void);
color: var(--text-muted);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.5;
}
.magnitude-selector__chip.active {
opacity: 1;
color: var(--text-primary);
border-color: var(--chip-color);
}
.magnitude-selector__swatch {
width: 1.2cqh;
height: 1.2cqh;
border-radius: 50%;
background: var(--chip-color);
flex: 0 0 auto;
}
@@ -3,10 +3,167 @@ import { InputChannel, Channel, OutputChannel } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import './GainSection.css'; import './GainSection.css';
import { DiscreteFrequency } from '../../types'; import {
import { DISCRETE_FREQUENCIES } from '../../constants'; GEQ_BANDS_LABEL,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
GRAPH_Y_MIN,
INPUT_ORDER,
OUTPUT_ORDER,
} from '../../constants';
import { useState } from 'react';
import LinearGraph from '../../../../components/LinearGraph/LinearGraph';
import { drawCurves } from '../../utils/graph/draw';
import { Curve, CurvePoint, sumCurvePoints } from '../../utils/graph/curve';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { getPeqCurvePoints } from '../../utils/graph/peq';
import { getCrossoverCurvePoints } from '../../utils/graph/crossover';
import { logSpace } from '../../utils/graph/curve';
const CHANNEL_COLORS = [
'#4ade80',
'#60a5fa',
'#f472b6',
'#fbbf24',
'#a78bfa',
'#34d399',
'#f87171',
'#38bdf8',
];
const GEQ_CURVE_Q = 5;
const Y_AXIS_STEP = 3; // must match the graph's yStep
function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
const map = {} as Record<T, string>;
channels.forEach((ch, i) => {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
});
return map;
}
// ---- Curve construction -----------------------------------------------
type ChannelCurves = {
geq: CurvePoint[];
peqCrossover: CurvePoint[];
total: CurvePoint[];
};
function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
return freqs.map((x) => ({ x, y: gainDb }));
}
function getPeqCrossoverPoints(
freqs: number[],
peqChain: { bands: Record<string, unknown> },
crossover: {
high_pass: { frequency: number; slope: unknown };
low_pass: { frequency: number; slope: unknown };
}
) {
return sumCurvePoints([
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }),
getCrossoverCurvePoints({
freqs,
highPass: { frequency: crossover.high_pass.frequency, filter_type: crossover.high_pass.slope as any },
lowPass: { frequency: crossover.low_pass.frequency, filter_type: crossover.low_pass.slope as any },
}),
]);
}
function getInputChannelCurves(
state: DSP['state']['current_config']['input_states'][InputChannel],
freqs: number[],
liveGain: number | undefined
): ChannelCurves {
const gain = liveGain ?? state.gain;
const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0);
const geq = getGeqCurvePoints({ freqs, values: geqValues, q: GEQ_CURVE_Q });
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq, peqCrossover, total };
}
function getOutputChannelCurves(
state: DSP['state']['current_config']['output_states'][OutputChannel],
freqs: number[],
liveGain: number | undefined
): ChannelCurves {
const gain = liveGain ?? state.gain;
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
const total = sumCurvePoints([peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq: [], peqCrossover, total };
}
// fill:false everywhere — with multiple overlapping curves the fill/fade
// just muddies things; stroke-only makes each channel/stage legible.
function buildCurvesForChannels<T extends string>(
channels: T[],
selected: Set<T>,
colors: Record<T, string>,
showStages: boolean,
getCurves: (channel: T) => ChannelCurves
): Curve[] {
const curves: Curve[] = [];
channels.forEach((channel) => {
if (!selected.has(channel)) return;
const { geq, peqCrossover, total } = getCurves(channel);
const color = colors[channel];
if (showStages) {
if (geq.length > 0) {
curves.push({
points: geq,
style: { strokeColor: color, fill: false, dimmed: true, lineWidth: 1 },
});
}
if (peqCrossover.length > 0) {
curves.push({
points: peqCrossover,
style: { strokeColor: color, fill: false, dimmed: true, lineWidth: 1 },
});
}
}
curves.push({ points: total, style: { strokeColor: color, fill: false, lineWidth: 2 } });
});
return curves;
}
// Expands [baseMin, baseMax] outward to fit whatever the curves actually
// contain (e.g. gain + geq + peq stacking past the nominal graph range),
// snapped to the axis step with one step of headroom so nothing touches
// the plot edge. Never shrinks below the base range.
function computeYAxisBounds(
curves: Curve[],
baseMin: number,
baseMax: number,
step: number
): { yMin: number; yMax: number } {
let dataMax = baseMax;
let dataMin = baseMin;
for (const curve of curves) {
for (const { y } of curve.points) {
if (y > dataMax) dataMax = y;
if (y < dataMin) dataMin = y;
}
}
const yMax = Math.ceil((dataMax + step) / step) * step;
const yMin = Math.floor((dataMin - step) / step) * step;
return { yMin, yMax };
}
// ---- Panels -------------------------------------------------------------
function GainPanel({ function GainPanel({
dsp, dsp,
@@ -15,6 +172,7 @@ function GainPanel({
setGain, setGain,
setMute, setMute,
setInverse, setInverse,
onLiveGain,
}: { }: {
dsp: DSP; dsp: DSP;
channel: Channel; channel: Channel;
@@ -22,6 +180,7 @@ function GainPanel({
setGain: (channel: Channel, gain: number) => Promise<boolean>; setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
onLiveGain: (value: number | null) => void;
}) { }) {
const channelState = const channelState =
'Input' in channel 'Input' in channel
@@ -40,6 +199,18 @@ function GainPanel({
await setInverse(channel, !inverted); await setInverse(channel, !inverted);
}; };
const handleLiveValue = (value: number) => {
onLiveGain(value);
};
const commitGain = async (value: number) => {
const success = await setGain(channel, value);
if (success) {
onLiveGain(null);
}
return success;
};
return ( return (
<div className="gain-panel"> <div className="gain-panel">
<div className="gain-panel__title">{title}</div> <div className="gain-panel__title">{title}</div>
@@ -51,7 +222,8 @@ function GainPanel({
unit=" dB" unit=" dB"
value={gain} value={gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))} formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => setGain(channel, value)} onInput={handleLiveValue}
onChange={commitGain}
/> />
<Button <Button
@@ -90,6 +262,40 @@ function GainPanel({
); );
} }
function ChannelSelector<T extends string>({
channels,
labels,
colors,
selected,
onToggle,
}: {
channels: T[];
labels: Record<T, string>;
colors: Record<T, string>;
selected: Set<T>;
onToggle: (channel: T) => void;
}) {
return (
<div className="magnitude-selector">
{channels.map((channel) => {
const isOn = selected.has(channel);
return (
<button
key={channel}
type="button"
className={`magnitude-selector__chip${isOn ? ' active' : ''}`}
style={{ '--chip-color': colors[channel] } as React.CSSProperties}
onClick={() => onToggle(channel)}
>
<span className="magnitude-selector__swatch" />
{labels[channel]}
</button>
);
})}
</div>
);
}
function GainSection({ function GainSection({
dsp, dsp,
setGain, setGain,
@@ -101,105 +307,192 @@ function GainSection({
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) { }) {
const inputs: InputChannel[] = [ const inputColors = buildColorMap(INPUT_ORDER);
InputChannel.InA, const outputColors = buildColorMap(OUTPUT_ORDER);
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [ const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_ORDER));
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' }, const [selectedOutputs, setSelectedOutputs] = useState<Set<OutputChannel>>(new Set(OUTPUT_ORDER));
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
const currentChannel = dsp.state.current_config.input_states[InputChannel.InA]; const [showInputStages, setShowInputStages] = useState(false);
const [showOutputStages, setShowOutputStages] = useState(false);
const [liveInputGains, setLiveInputGains] = useState<Partial<Record<InputChannel, number>>>({});
const [liveOutputGains, setLiveOutputGains] = useState<Partial<Record<OutputChannel, number>>>({});
const setLiveInputGain = (channel: InputChannel, value: number | null) => {
setLiveInputGains((prev) => {
if (value === null) {
const next = { ...prev };
delete next[channel];
return next;
}
return { ...prev, [channel]: value };
});
};
const setLiveOutputGain = (channel: OutputChannel, value: number | null) => {
setLiveOutputGains((prev) => {
if (value === null) {
const next = { ...prev };
delete next[channel];
return next;
}
return { ...prev, [channel]: value };
});
};
const inputLabels: Record<InputChannel, string> = INPUT_ORDER.reduce(
(acc, channel) => ({
...acc,
[channel]: dsp.state.current_config.input_states[channel].name,
}),
{} as Record<InputChannel, string>
);
const outputLabels: Record<OutputChannel, string> = OUTPUT_ORDER.reduce(
(acc, channel) => ({
...acc,
[channel]: dsp.state.current_config.output_states[channel].name,
}),
{} as Record<OutputChannel, string>
);
const toggleInput = (channel: InputChannel) => {
setSelectedInputs((prev) => {
const next = new Set(prev);
next.has(channel) ? next.delete(channel) : next.add(channel);
return next;
});
};
const toggleOutput = (channel: OutputChannel) => {
setSelectedOutputs((prev) => {
const next = new Set(prev);
next.has(channel) ? next.delete(channel) : next.add(channel);
return next;
});
};
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
const inputCurves = buildCurvesForChannels(
INPUT_ORDER,
selectedInputs,
inputColors,
showInputStages,
(channel) =>
getInputChannelCurves(dsp.state.current_config.input_states[channel], freqs, liveInputGains[channel])
);
const outputCurves = buildCurvesForChannels(
OUTPUT_ORDER,
selectedOutputs,
outputColors,
showOutputStages,
(channel) =>
getOutputChannelCurves(dsp.state.current_config.output_states[channel], freqs, liveOutputGains[channel])
);
const { yMin: inputYMin, yMax: inputYMax } = computeYAxisBounds(
inputCurves,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
Y_AXIS_STEP
);
const { yMin: outputYMin, yMax: outputYMax } = computeYAxisBounds(
outputCurves,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
Y_AXIS_STEP
);
return ( return (
<> <>
<div className="gain-linear-graph-section"> <div className="gain-linear-graph-section">
<div className="gain-graph-panel"> <div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div> <div className="gain-graph-title-row">
<div className="gain-graph-title">Input Response</div>
<Button
variant="toggle"
className={showInputStages ? 'active' : ''}
onClick={() => setShowInputStages((v) => !v)}
>
Show stages
</Button>
<ChannelSelector
channels={INPUT_ORDER}
labels={inputLabels}
colors={inputColors}
selected={selectedInputs}
onToggle={toggleInput}
/>
</div>
<LinearGraph <LinearGraph
xMin={10} xMin={GRAPH_X_MIN}
xMax={30000} xMax={GRAPH_X_MAX}
xScale="log" xScale="log"
yMin={-18} yMin={inputYMin}
yMax={18} yMax={inputYMax}
yStep={3} yStep={Y_AXIS_STEP}
xLabel="Hz" xLabel="Hz"
yLabel="dB" yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => { draw={({ ctx, toPx }) => {
const geq = currentChannel.geq; drawCurves({
ctx,
if (geq.bypass) { toPx,
return; xMin: GRAPH_X_MIN,
} xMax: GRAPH_X_MAX,
yMin: inputYMin,
const points = Object.entries(geq.gains) yMax: inputYMax,
.map(([frequency, gain]) => ({ curves: inputCurves,
frequency: DISCRETE_FREQUENCIES[frequency as DiscreteFrequency],
gain,
}))
.sort((a, b) => a.frequency - b.frequency)
.map(({ frequency, gain }) => {
const [x, y] = toPx(frequency, gain);
return { x, y };
}); });
if (points.length < 2) {
return;
}
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const previous = points[i - 1];
const current = points[i];
const midX = (previous.x + current.x) / 2;
ctx.quadraticCurveTo(previous.x, previous.y, midX, (previous.y + current.y) / 2);
ctx.quadraticCurveTo(current.x, current.y, current.x, current.y);
}
ctx.stroke();
}} }}
/> />
</div> </div>
<div className="gain-graph-panel"> <div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div> <div className="gain-graph-title-row">
<div className="gain-graph-title">Output Response</div>
<Button
variant="toggle"
className={showOutputStages ? 'active' : ''}
onClick={() => setShowOutputStages((v) => !v)}
>
Show stages
</Button>
<ChannelSelector
channels={OUTPUT_ORDER}
labels={outputLabels}
colors={outputColors}
selected={selectedOutputs}
onToggle={toggleOutput}
/>
</div>
<LinearGraph <LinearGraph
xMin={20} xMin={GRAPH_X_MIN}
xMax={20000} xMax={GRAPH_X_MAX}
yMin={-180} xScale="log"
yMax={180} yMin={outputYMin}
yStep={45} yMax={outputYMax}
yStep={Y_AXIS_STEP}
xLabel="Hz" xLabel="Hz"
yLabel="deg" yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => { draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80'; drawCurves({
ctx.lineWidth = 2; ctx,
const [x0, y0] = toPx(20, 0); toPx,
const [x1, y1] = toPx(20000, 0); xMin: GRAPH_X_MIN,
ctx.beginPath(); xMax: GRAPH_X_MAX,
ctx.moveTo(x0, y0); yMin: outputYMin,
ctx.lineTo(x1, y1); yMax: outputYMax,
ctx.stroke(); curves: outputCurves,
});
}} }}
/> />
</div> </div>
@@ -208,7 +501,7 @@ function GainSection({
<div className="gain-section"> <div className="gain-section">
<div className="gain-panels"> <div className="gain-panels">
<ChannelGroup title="Input"> <ChannelGroup title="Input">
{inputs.map((channel) => ( {INPUT_ORDER.map((channel) => (
<GainPanel <GainPanel
key={channel} key={channel}
dsp={dsp} dsp={dsp}
@@ -217,20 +510,22 @@ function GainSection({
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveInputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
<ChannelGroup title="Output"> <ChannelGroup title="Output">
{outputs.map(({ channel, title }) => ( {OUTPUT_ORDER.map((channel) => (
<GainPanel <GainPanel
key={title} key={channel}
dsp={dsp} dsp={dsp}
channel={channel} channel={{ Output: channel }}
title={title} title={dsp.state.current_config.output_states[channel].name}
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveOutputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
@@ -4,21 +4,23 @@ import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { GEQ_BAND_HZ, buildGeqCurveDraw } from '../../utils/geqCurve'; import { drawCurves } from '../../utils/graph/draw';
import { import {
INPUT_ORDER, INPUT_ORDER,
GRAPH_X_MIN, GRAPH_X_MIN,
GRAPH_X_MAX, GRAPH_X_MAX,
GRAPH_Y_MIN, GRAPH_Y_MIN,
GRAPH_Y_MAX, GRAPH_Y_MAX,
GRAPH_GEQ_CURVE_Q,
GEQ_GAIN_MIN, GEQ_GAIN_MIN,
GEQ_GAIN_MAX, GEQ_GAIN_MAX,
GEQ_GAIN_STEP, GEQ_GAIN_STEP,
GEQ_BANDS_LABEL, GEQ_BANDS_LABEL,
GEQ_CURVE_Q,
} from '../../constants'; } from '../../constants';
import { useState } from 'react'; import { useState } from 'react';
import './GeqSection.css'; import './GeqSection.css';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { logSpace } from '../../utils/graph/curve';
type LiveBands = Partial<Record<DiscreteFrequency, number>>; type LiveBands = Partial<Record<DiscreteFrequency, number>>;
@@ -83,6 +85,7 @@ function GeqGraph({
const values = GEQ_BANDS_LABEL.map( const values = GEQ_BANDS_LABEL.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0 ({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
); );
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return ( return (
<div className="geq-graph-panel"> <div className="geq-graph-panel">
@@ -97,16 +100,23 @@ function GeqGraph({
xLabel="Band Hz" xLabel="Band Hz"
yLabel="dB" yLabel="dB"
showGrid={true} showGrid={true}
draw={buildGeqCurveDraw({ draw={({ ctx, toPx }) => {
values, drawCurves({
bandHz: GEQ_BAND_HZ, ctx,
q: GRAPH_GEQ_CURVE_Q * 0.5, toPx,
xMin: GRAPH_X_MIN, xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX, xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN, yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX, yMax: GRAPH_Y_MAX,
dimmed: geq.bypass, curves: [
})} {
points: getGeqCurvePoints({ freqs, values, q: GEQ_CURVE_Q }),
style: { strokeColor: '#e5e7eb', dimmed: geq.bypass },
},
],
});
}}
/> />
{geq.bypass && ( {geq.bypass && (
<div className="geq-bypass-overlay"> <div className="geq-bypass-overlay">
@@ -208,7 +218,7 @@ function GeqSection({
}) { }) {
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]); const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({}); const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
const [resetting, setResetting] = useState(false); // NEW const [resetting, setResetting] = useState(false);
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => { const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({ setLiveGeq((prev) => ({
@@ -231,7 +241,11 @@ function GeqSection({
return success; return success;
}; };
// NEW: sequential per-band reset, since hardware has no bulk reset command. const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
// Sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => { const handleReset = async (channel: InputChannel) => {
if (resetting) return; if (resetting) return;
setResetting(true); setResetting(true);
@@ -251,10 +265,6 @@ function GeqSection({
const activeGeq = activeInputState.geq; const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel]; const activeLiveBands = liveGeq[activeChannel];
const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const channelOptions = INPUT_ORDER.map((channel) => ({ const channelOptions = INPUT_ORDER.map((channel) => ({
channel, channel,
label: dsp.state.current_config.input_states[channel].name, label: dsp.state.current_config.input_states[channel].name,
@@ -163,7 +163,7 @@
position: relative; position: relative;
height: 10; height: 10;
aspect-ratio: 0.75; aspect-ratio: 0.8;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -228,7 +228,7 @@
.crossover-panel { .crossover-panel {
position: relative; position: relative;
height: 100%; height: 100%;
aspect-ratio: 0.28; aspect-ratio: 0.3;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -361,3 +361,47 @@
box-sizing: border-box; box-sizing: border-box;
} }
.input-graph-panel__magnitude-wrap {
flex: 1 1 0;
min-height: 0;
min-width: 0;
position: relative;
}
.input-graph__title-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1cqh 1.5cqh;
}
.input-graph__title-row .input-graph__title {
padding: 0; /* was on the standalone title element; row now owns spacing */
}
.input-graph-view-toggle {
display: flex;
gap: 0.4cqh;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
padding: 0.3cqh;
background: var(--bg-void);
}
.input-graph-view-toggle__btn {
font-family: var(--font-ui);
font-size: 2cqh;
padding: 0.6cqh 1.2cqh;
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.input-graph-view-toggle__btn.active {
background: var(--accent, #4dd0e1);
color: var(--bg-void);
}
@@ -3,171 +3,239 @@ import VerticalSlider, {
makeLogStep, makeLogStep,
makeLogQuantize, makeLogQuantize,
} from '../../../../components/VerticalSlider/VerticalSlider'; } from '../../../../components/VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping import {
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values CROSSOVER_FILTER_LABEL,
// if the actual enum differs. PEQ_BANDS_ORDER,
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../types'; PEQ_FILTERS_LABEL,
PEQ_Q_RANGES,
PEAK_Q_VALUES,
} from '../../constants';
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types'; import { CrossoverFilter } from '../../types';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import './InputSection.css'; import './InputSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import MeterBar from '../../../../components/MeterBar/MeterBar'; // import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
import {
FREQUENCY_MAX,
FREQUENCY_MIN,
FREQUENCY_QUANTIZATION,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
GRAPH_Y_MIN,
} from '../../constants';
import { drawCurves } from '../../utils/graph/draw';
import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq';
import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve';
import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover';
const PEQ_BANDS: PEQBand[] = [ type GraphView = 'both' | 'magnitude' | 'phase';
PEQBand.B1,
PEQBand.B2, function GraphViewToggle({
PEQBand.B3, view,
PEQBand.B4, onChange,
PEQBand.B5, }: {
PEQBand.B6, view: GraphView;
PEQBand.B7, onChange: (v: GraphView) => void;
PEQBand.B8, }) {
const options: { value: GraphView; label: string }[] = [
{ value: 'both', label: 'Both' },
{ value: 'magnitude', label: 'Mag' },
{ value: 'phase', label: 'Phase' },
]; ];
// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types return (
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [ <div className="input-graph-view-toggle">
{ value: PEQFilter.Peak, label: 'Peak' }, {options.map((opt) => (
{ value: PEQFilter.LowShelf, label: 'Low Shelf' }, <button
{ value: PEQFilter.HighShelf, label: 'High Shelf' }, key={opt.value}
]; type="button"
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [ function InputActions({
{ value: CrossoverFilter.Bw6, label: 'BW-6' }, bypass,
{ value: CrossoverFilter.Bw12, label: 'BW-12' }, resetting,
{ value: CrossoverFilter.Bw18, label: 'BW-18' }, onBypass,
{ value: CrossoverFilter.Bw24, label: 'BW-24' }, onReset,
{ value: CrossoverFilter.Bw30, label: 'BW-30' }, }: {
{ value: CrossoverFilter.Bw36, label: 'BW-36' }, bypass: boolean;
{ value: CrossoverFilter.Bw42, label: 'BW-42' }, resetting: boolean;
{ value: CrossoverFilter.Bw48, label: 'BW-48' }, onBypass: () => void;
{ value: CrossoverFilter.Lk12, label: 'LR-12' }, onReset: () => void;
{ value: CrossoverFilter.Lk24, label: 'LR-24' }, }) {
{ value: CrossoverFilter.Lk36, label: 'LR-36' }, return (
{ value: CrossoverFilter.Lk48, label: 'LR-48' }, <div className="geq-channel-selector">
]; <Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={onBypass}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
const PEQ_SLIDER_PARAMS = [ <Button
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' }, variant="toggle"
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' }, onClick={onReset}
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' }, disabled={resetting}
] as const; style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{resetting ? 'Resetting…' : 'Reset'}
</Button>
</div>
);
}
type SetPEQFn = ( function PeqPanelSliders({
channel: Channel, peq,
band: PEQBand, setPEQ,
onLiveValue,
}: {
peq: PEQ;
setPEQ: (
gainDb: number, gainDb: number,
freqHz: number, freqHz: number,
q: number, q: number,
filterType: PEQFilter, filterType: PEQFilter,
bypass: boolean bypass: boolean
) => Promise<boolean>; ) => Promise<boolean>;
onLiveValue: (value: PEQ) => void;
type PeqParamKey = 'frequency' | 'q' | 'gain';
type PEQValues = {
frequency: number;
q: number;
gain: number;
filter_type: PEQFilter;
bypass: boolean;
};
function PeqPanelSliders({
channel,
dsp,
band,
setPEQ,
// onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
band: PEQBand;
setPEQ: SetPEQFn;
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
}) { }) {
const inputState = dsp.state.current_config.input_states[channel]; const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => { const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides }; const next = { ...peq, ...overrides };
return setPEQ( return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
}; };
return ( return (
<div className="input-panel"> <div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div> <div className="input-panel__title">PEQ Parameters</div>
<div className="input-panel__grid"> <div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => ( <div className="input-panel__cell input-panel__cell--frequency">
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider <VerticalSlider
min={param.min} min={FREQUENCY_MIN}
max={param.max} max={FREQUENCY_MAX}
step={() => param.step} scale="log"
unit={param.unit} step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
value={peq[param.key]} quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
// onInput={(value) => onLiveValue(channel, param.key, value)} unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)} formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
value={peq.frequency}
onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue({ ...peq, frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<div className="input-panel__cell-label">{param.label}</div>
<div className="input-panel__cell-label">Frequency</div>
</div>
<div className="input-panel__cell input-panel__cell--q">
<VerticalSlider
min={qMin}
max={qMax}
values={PEAK_Q_VALUES}
formatValue={(v) => v.toFixed(2)}
value={peq.q}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">Q</div>
</div>
<div className="input-panel__cell input-panel__cell--gain">
<VerticalSlider
min={-12}
max={12}
step={() => 0.1}
unit="dB"
value={peq.gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => commit({ gain: value })}
onInput={(value) => onLiveValue({ ...peq, gain: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">Gain</div>
</div> </div>
))}
</div> </div>
</div> </div>
); );
} }
const FREQ_MIN = 19.7;
const FREQ_MAX = 20160;
const FREQ_POINTS = 300;
const FREQ_STEP = makeLogStep(FREQ_MIN, FREQ_MAX, FREQ_POINTS);
const FREQ_QUANTIZE = makeLogQuantize(FREQ_MIN, FREQ_MAX, FREQ_POINTS);
function CrossoverPanel({ function CrossoverPanel({
channel, crossover,
dsp,
title, title,
setCrossover, setCrossover,
onLiveValue,
}: { }: {
channel: Channel; crossover: Crossover;
dsp: DSP;
title: string; title: string;
setCrossover: ( setCrossover: (frequency: number, filter_type: CrossoverFilter) => Promise<boolean>;
channel: Channel, onLiveValue: (value: number) => void;
frequency: number,
filter_type: CrossoverFilter
) => Promise<boolean>;
}) { }) {
const channelState = const frequency = crossover.frequency;
'Input' in channel const filter_type = crossover.slope;
? dsp.state?.current_config.input_states[channel.Input] const bypass = filter_type === CrossoverFilter.Bypass;
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover; const lastFilterType = useRef<CrossoverFilter>(
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
);
const frequency = crossover.high_pass.frequency; const commit = async (overrides: {
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number; frequency?: number;
filter_type?: CrossoverFilter; filter_type?: CrossoverFilter;
bypass?: boolean; bypass?: boolean;
}) => { }) => {
const next = { frequency, filter_type, bypass, ...overrides }; const nextFrequency = overrides.frequency ?? frequency;
return setCrossover(channel, next.frequency, next.filter_type);
const selectedFilterType =
overrides.filter_type !== undefined
? overrides.filter_type
: bypass
? lastFilterType.current
: filter_type;
// Always remember the latest non-bypass selection.
if (selectedFilterType !== CrossoverFilter.Bypass) {
lastFilterType.current = selectedFilterType;
}
const nextBypass = overrides.bypass ?? bypass;
const nextFilterType = nextBypass ? CrossoverFilter.Bypass : selectedFilterType;
return setCrossover(nextFrequency, nextFilterType);
}; };
return ( return (
@@ -175,23 +243,27 @@ function CrossoverPanel({
<div className="crossover-panel__title">{title}</div> <div className="crossover-panel__title">{title}</div>
<VerticalSlider <VerticalSlider
min={FREQ_MIN} min={FREQUENCY_MIN}
max={FREQ_MAX} max={FREQUENCY_MAX}
scale="log" scale="log"
step={FREQ_STEP} step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={FREQ_QUANTIZE} quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit=" Hz" unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
value={frequency} value={frequency}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
onChange={(value) => commit({ frequency: value })} onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue(value)}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<select <select
className="crossover-panel__select" className="crossover-panel__select"
value={filter_type} value={bypass ? lastFilterType.current : filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })} onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
> >
{FILTER_TYPE_OPTIONS.map((opt) => ( {CROSSOVER_FILTER_LABEL.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
@@ -219,32 +291,29 @@ function CrossoverPanel({
} }
function PeqBandList({ function PeqBandList({
channel, bands,
dsp, liveBands,
activeBand, activeBand,
setActiveBand, setActiveBand,
setPEQ, setPEQ,
}: { }: {
channel: InputChannel; bands: Record<PEQBand, PEQ>;
dsp: DSP; liveBands: Partial<Record<PEQBand, PEQ>>;
activeBand: PEQBand; activeBand: PEQBand;
setActiveBand: (band: PEQBand) => void; setActiveBand: (band: PEQBand) => void;
setPEQ: SetPEQFn; setPEQ: (
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
}) { }) {
const inputState = dsp.state.current_config.input_states[channel]; const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const peq = liveBands[band] ?? bands[band];
const commit = (band: PEQBand, overrides: Partial<PEQValues>) => {
const peq = inputState.peq_chain.bands[band] as PEQ;
const next = { ...peq, ...overrides }; const next = { ...peq, ...overrides };
return setPEQ( return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
}; };
return ( return (
@@ -258,8 +327,8 @@ function PeqBandList({
<div className="peq-band-list__cell">Bypass</div> <div className="peq-band-list__cell">Bypass</div>
</div> </div>
{PEQ_BANDS.map((band, i) => { {PEQ_BANDS_ORDER.map((band, i) => {
const peq = inputState.peq_chain.bands[band] as PEQ; const peq = liveBands[band] ?? bands[band];
const isActive = band === activeBand; const isActive = band === activeBand;
return ( return (
@@ -276,14 +345,22 @@ function PeqBandList({
{`B${i + 1}`} {`B${i + 1}`}
</Button> </Button>
{/* Frequency */}
<input <input
className="peq-band-list__input" className="peq-band-list__input"
type="number" type="text"
step={0.5} value={
min={19.7} peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
max={20160} }
value={peq.frequency} onChange={(e) => {
onChange={(e) => commit(band, { frequency: Number(e.target.value) })} const value = Number(e.target.value);
if (!Number.isNaN(value)) {
commit(band, {
frequency: peq.frequency >= 1000 ? value * 1000 : value,
});
}
}}
/> />
<input <input
@@ -309,9 +386,9 @@ function PeqBandList({
<select <select
className="peq-band-list__select" className="peq-band-list__select"
value={peq.filter_type} value={peq.filter_type}
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter })} onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter, q: 3.0 })}
> >
{PEQ_FILTER_TYPE_OPTIONS.map((opt) => ( {PEQ_FILTERS_LABEL.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
@@ -339,15 +416,74 @@ type SetFilterFn = (
filterType: CrossoverFilter filterType: CrossoverFilter
) => Promise<boolean>; ) => Promise<boolean>;
type setPeqBandFn = ( /** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
channel: Channel, // function GraphMarkers({
band: PEQBand, // graphRef,
gainDb: number, // bands,
freqHz: number, // activeBand,
q: number, // setActiveBand,
filterType: PEQFilter, // hpFreq,
bypass: boolean // hpBypassed,
) => Promise<boolean>; // lpFreq,
// lpBypassed,
// }: {
// graphRef: React.RefObject<LinearGraphHandle>;
// bands: Record<PEQBand, PEQ>;
// activeBand: PEQBand;
// setActiveBand: (band: PEQBand) => void;
// hpFreq: number;
// hpBypassed: boolean;
// lpFreq: number;
// lpBypassed: boolean;
// }) {
// const g = graphRef.current;
// if (!g) return null;
// return (
// <>
// {PEQ_BANDS_ORDER.map((band, i) => {
// const peq = bands[band];
// const left = g.toPxX(peq.frequency);
// const top = g.toPxY(peq.gain);
// const isActive = band === activeBand;
// return (
// <button
// key={band}
// type="button"
// className={`graph-marker graph-marker--peq${isActive ? ' graph-marker--active' : ''}${
// peq.bypass ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left, top }}
// onClick={() => setActiveBand(band)}
// title={`B${i + 1} · ${Math.round(peq.frequency)} Hz · ${peq.gain.toFixed(1)} dB`}
// >
// {`B${i + 1}`}
// </button>
// );
// })}
// <div
// className={`graph-marker graph-marker--crossover graph-marker--hp${
// hpBypassed ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left: g.toPxX(hpFreq), top: g.toPxY(0) }}
// title={`HPF · ${Math.round(hpFreq)} Hz`}
// >
// HP
// </div>
// <div
// className={`graph-marker graph-marker--crossover graph-marker--lp${
// lpBypassed ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left: g.toPxX(lpFreq), top: g.toPxY(0) }}
// title={`LPF · ${Math.round(lpFreq)} Hz`}
// >
// LP
// </div>
// </>
// );
// }
function InputSection({ function InputSection({
dsp, dsp,
@@ -361,171 +497,252 @@ function InputSection({
channel: InputChannel; channel: InputChannel;
setHighPass: SetFilterFn; setHighPass: SetFilterFn;
setLowPass: SetFilterFn; setLowPass: SetFilterFn;
setPeq: setPeqBandFn; setPeq: (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>; setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
}) { }) {
// UI variables
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1); const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false);
const [liveHpFreq, setLiveHpFreq] = useState<number>(19.7); // Live values
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16); const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({}); const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQ>>>({});
const inputState = dsp.state.current_config.input_states[channel]; const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
const hp = inputState.crossover.high_pass; // DSP values
const lp = inputState.crossover.low_pass; const activeInputState = dsp.state.current_config.input_states[channel];
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues); const ActiveHighPassFilter = activeInputState.crossover.high_pass;
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
const ActivePeq = activeInputState.peq_chain;
const bypassed = hp.slope === CrossoverFilter.Bypass; const handleResetPeq = async () => {
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope; if (resetting) return;
setResetting(true);
// Remembers the last non-bypass slope so the Bypass button can restore it. for (const band of PEQ_BANDS_ORDER) {
// const lastNonBypassType = useRef<CrossoverFilter>( const current = displayPeq[band];
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType if (current.gain === 0 || current.filter_type === PEQFilter.Peak) continue; // skip bands already flat
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency; await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass);
const lpFreq = liveLpFreq ?? lp.frequency; }
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => { setResetting(false);
// const success = await set_high_pass({ Input: channel }, freq, filterType); };
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => { const handleBypassClick = () => {
// const success = await set_low_pass({ Input: channel }, freq, filterType); setPeqBypass({ Input: channel }, !ActivePeq.bypass);
// if (success) setLiveLpFreq(null); };
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => { // Live handlers
// // Applying a slope change always takes the crossover out of bypass. const handleLiveHighPassValue = (value: number) => {
// void commitHighPass(hpFreq, filterType); setLiveHpFreq(value);
// void commitLowPass(lpFreq, filterType); };
// };
// const handleBypassToggle = () => { const handleLiveLowPassValue = (value: number) => {
// if (bypassed) { setLiveLpFreq(value);
// const restore = lastNonBypassType.current; };
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore); const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
// } else { setLivePeq((prev) => ({
// void commitHighPass(hpFreq, CrossoverFilter.Bypass); ...prev,
// void commitLowPass(lpFreq, CrossoverFilter.Bypass); [band]: value,
// } }));
// }; };
// Real dsp value handlers
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setHighPass({ Input: channel }, freq, filterType);
if (success) {
setLiveHpFreq(freq);
}
return success;
};
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setLowPass({ Input: channel }, freq, filterType);
if (success) {
setLiveLpFreq(freq);
}
return success;
};
const commitPEQ = async (
band: PEQBand,
gain: number,
frequency: number,
q: number,
filter_type: PEQFilter,
bypass: boolean
) => {
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
if (success) {
setLivePeq((prev) => {
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 };
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return ( return (
<> <>
<div className="input-graph-section"> <div className="input-graph-section">
<div className="input-graph-panel"> <div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div> <div className="input-graph__title-row">
<div className="input-graph__title">{activeInputState.name} - Filters</div>
<GraphViewToggle view={graphView} onChange={setGraphView} />
</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined, {(graphView === 'both' || graphView === 'magnitude') && (
same pattern as LimiterSection's `draw` prop. */} <div className="input-graph-panel__magnitude-wrap">
<LinearGraph <LinearGraph
xMin={20} ref={magnitudeGraphRef}
xMax={20000} xMin={GRAPH_X_MIN}
yMin={-30} xMax={GRAPH_X_MAX}
yMax={6} xScale="log"
yMin={GRAPH_Y_MIN}
yMax={GRAPH_Y_MAX}
yStep={3}
xLabel="Hz" xLabel="Hz"
yLabel="dB" yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()} formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()} formatY={(v) => Math.round(v).toString()}
draw={() => {}} draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
curves: [
{
points: sumCurvePoints([
getPeqCurvePoints({ freqs, bands: displayPeq }),
getCrossoverCurvePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/> />
</div> </div>
)}
{(graphView === 'both' || graphView === 'phase') && (
<LinearGraph
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="°"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
curves: [
{
points: sumPhaseCurvePoints([
getPeqPhasePoints({ freqs, bands: displayPeq }),
getCrossoverPhasePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/>
)}
</div>
</div> </div>
<div className="input-control-section"> <div className="input-control-section">
<div className="input-control-panel"> <div className="input-control-panel">
<ChannelGroup title="PEQ"> <ChannelGroup title="PEQ">
<PeqBandList <PeqBandList
channel={channel} bands={ActivePeq.bands}
dsp={dsp} liveBands={livePeq}
activeBand={activeBand} activeBand={activeBand}
setActiveBand={setActiveBand} setActiveBand={setActiveBand}
setPEQ={setPeq} setPEQ={commitPEQ}
/>
<PeqPanelSliders
peq={ActivePeq.bands[activeBand]}
setPEQ={(gain, frequency, q, filter_type, bypass) =>
commitPEQ(activeBand, gain, frequency, q, filter_type, bypass)
}
onLiveValue={(value) => handleLivePEQValue(activeBand, value)}
/> />
<PeqPanelSliders channel={channel} dsp={dsp} band={activeBand} setPEQ={setPeq} />
</ChannelGroup> </ChannelGroup>
<ChannelGroup title="CrossOver"> <ChannelGroup title="CrossOver">
<CrossoverPanel <CrossoverPanel
channel={{ Input: channel }} crossover={ActiveHighPassFilter}
dsp={dsp} title="HighPass"
title="LowPass" setCrossover={commitHighPass}
setCrossover={setHighPass} onLiveValue={handleLiveHighPassValue}
/> />
<CrossoverPanel <CrossoverPanel
channel={{ Input: channel }} crossover={ActiveLowPassFilter}
dsp={dsp} title="LowPass"
title="HighPass" setCrossover={commitLowPass}
setCrossover={setHighPass} onLiveValue={handleLiveLowPassValue}
/> />
</ChannelGroup> </ChannelGroup>
{/* <div className="input-control-panel__sliders">
<div className="input-control-panel__cell input-control-panel__cell--high-pass"> <ChannelGroup title="Actions">
<VerticalSlider <InputActions
min={FREQ_MIN} bypass={ActivePeq.bypass}
max={FREQ_MAX} resetting={resetting}
step={FREQ_STEP} onBypass={handleBypassClick}
unit=" Hz" onReset={handleResetPeq}
value={hpFreq}
// disabled={bypassed}
onInput={(value) => setLiveHpFreq(value)}
onChange={(value) => commitHighPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/> />
<div className="input-control-panel__cell-label">High Pass</div> </ChannelGroup>
</div>
<div className="input-control-panel__cell input-control-panel__cell--low-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={lpFreq}
// disabled={bypassed}
onInput={(value) => setLiveLpFreq(value)}
onChange={(value) => commitLowPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">Low Pass</div>
</div>
</div>
<div className="input-control-panel__side">
<label className="input-control-panel__select">
<span className="input-control-panel__select-label">Filter Type</span>
<select
value={activeFilterType}
disabled={bypassed}
onChange={(e) => handleFilterTypeChange(e.target.value as CrossoverFilter)}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
className={`input-control-panel__bypass-btn${
bypassed ? ' input-control-panel__bypass-btn--active' : ''
}`}
onClick={handleBypassToggle}
>
{bypassed ? 'Bypassed' : 'Bypass'}
</button>
</div> */}
</div> </div>
</div> </div>
</> </>
+82 -2
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[] = [ export const INPUT_ORDER: InputChannel[] = [
InputChannel.InA, InputChannel.InA,
@@ -53,7 +61,6 @@ export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
}; };
// ------ GEQ constants ------ // ------ GEQ constants ------
export const GRAPH_GEQ_CURVE_Q = 8.65;
export const GEQ_BANDS_LABEL = [ export const GEQ_BANDS_LABEL = [
{ frequency: DiscreteFrequency.F20, label: '20' }, { frequency: DiscreteFrequency.F20, label: '20' },
@@ -92,12 +99,85 @@ export const GEQ_BANDS_LABEL = [
export const GEQ_GAIN_MIN = -12; export const GEQ_GAIN_MIN = -12;
export const GEQ_GAIN_MAX = 12; export const GEQ_GAIN_MAX = 12;
export const GEQ_GAIN_STEP = 0.1; export const GEQ_GAIN_STEP = 0.1;
export const GEQ_CURVE_Q = 8.65 * 0.5;
// ------ Filter constants ------
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;
// ------ Matrix constants ------ // ------ Matrix constants ------
export const MATRIX_GAIN_MIN = -60; export const MATRIX_GAIN_MIN = -60;
export const MATRIX_GAIN_MAX = 0; export const MATRIX_GAIN_MAX = 0;
export const MATRIX_GAIN_STEP = 0.1; export const MATRIX_GAIN_STEP = 0.1;
// ------ Delay constants ------
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
// ------ Graph constants ------ // ------ Graph constants ------
export const GRAPH_X_MIN = 10; export const GRAPH_X_MIN = 10;
export const GRAPH_X_MAX = 30000; export const GRAPH_X_MAX = 30000;
+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),
};
+62 -13
View File
@@ -11,11 +11,12 @@ import {
PEQFilter, PEQFilter,
PEQBand, PEQBand,
InputSource, InputSource,
Crossover,
} from './types'; } from './types';
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared'; import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
import { registerPollStopper } from '../../dsp/pollRegistry'; import { registerPollStopper } from '../../dsp/pollRegistry';
import { defaultDSPState, defaultMeters } from './types'; import { defaultDSPState, defaultMeters } from './defaults';
import { DELAY_RANGES } from './types'; import { DELAY_RANGES, PEQ_Q_RANGES } from './constants';
type DSP408 = Extract<DSP, { type: 'DSP408' }>; type DSP408 = Extract<DSP, { type: 'DSP408' }>;
@@ -1144,9 +1145,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const lowPass = {
const lowPass: Crossover = {
frequency: freq, frequency: freq,
filter_type: filterType, slope: filterType,
}; };
if ('Input' in channel) { if ('Input' in channel) {
@@ -1160,11 +1162,14 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.input_states, ...cfg.input_states,
[channel.Input]: { [channel.Input]: {
...cfg.input_states[channel.Input], ...cfg.input_states[channel.Input],
crossover: {
...cfg.input_states[channel.Input].crossover,
low_pass: lowPass, low_pass: lowPass,
}, },
}, },
}, },
}, },
},
}; };
} }
@@ -1178,11 +1183,14 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.output_states, ...cfg.output_states,
[channel.Output]: { [channel.Output]: {
...cfg.output_states[channel.Output], ...cfg.output_states[channel.Output],
crossover: {
...cfg.output_states[channel.Output].crossover,
low_pass: lowPass, low_pass: lowPass,
}, },
}, },
}, },
}, },
},
}; };
}); });
}; };
@@ -1227,9 +1235,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const highPass = {
const highPass: Crossover = {
frequency: freq, frequency: freq,
filter_type: filterType, slope: filterType,
}; };
if ('Input' in channel) { if ('Input' in channel) {
@@ -1243,11 +1252,14 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.input_states, ...cfg.input_states,
[channel.Input]: { [channel.Input]: {
...cfg.input_states[channel.Input], ...cfg.input_states[channel.Input],
crossover: {
...cfg.input_states[channel.Input].crossover,
high_pass: highPass, high_pass: highPass,
}, },
}, },
}, },
}, },
},
}; };
} }
@@ -1261,11 +1273,14 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.output_states, ...cfg.output_states,
[channel.Output]: { [channel.Output]: {
...cfg.output_states[channel.Output], ...cfg.output_states[channel.Output],
crossover: {
...cfg.output_states[channel.Output].crossover,
high_pass: highPass, high_pass: highPass,
}, },
}, },
}, },
}, },
},
}; };
}); });
}; };
@@ -1310,20 +1325,40 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
bypass: boolean bypass: boolean
): Promise<boolean> { ): Promise<boolean> {
const updatePeqBand = () => { const updatePeqBand = () => {
function convertPeqQ(value: number, from: PEQFilter, to: PEQFilter): number {
if (from === to) return value;
const [fromMin, fromMax] = PEQ_Q_RANGES[from];
const [toMin, toMax] = PEQ_Q_RANGES[to];
if (fromMax === fromMin) return toMin;
const ratio = (value - fromMin) / (fromMax - fromMin);
return toMin + ratio * (toMax - toMin);
}
updateDSP(setDsps, dsp.id, (d) => { updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q,
filter_type: filterType,
bypass,
};
if ('Input' in channel) { if ('Input' in channel) {
const inputState = cfg.input_states[channel.Input]; const inputState = cfg.input_states[channel.Input];
const existingBand = inputState.peq_chain.bands[band];
const nextQ =
existingBand.filter_type !== filterType
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
: q;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q: nextQ,
filter_type: filterType,
bypass,
};
return { return {
...d, ...d,
@@ -1350,6 +1385,20 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
} }
const outputState = cfg.output_states[channel.Output]; const outputState = cfg.output_states[channel.Output];
const existingBand = outputState.peq_chain.bands[band];
const nextQ =
existingBand.filter_type !== filterType
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
: q;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q: nextQ,
filter_type: filterType,
bypass,
};
return { return {
...d, ...d,
-182
View File
@@ -247,185 +247,3 @@ export interface Meter {
export interface Meters { export interface Meters {
channels: Meter[]; channels: Meter[];
} }
/* -- Default Value -- */
const createDefaultGate = (): Gate => ({
attack: 10,
release: 10,
hold: 10,
threshold: 0,
});
const createDefaultGraphicEQ = (): GraphicEQ => ({
gains: {
[DiscreteFrequency.F20]: 0,
[DiscreteFrequency.F25]: 0,
[DiscreteFrequency.F31_5]: 0,
[DiscreteFrequency.F40]: 0,
[DiscreteFrequency.F50]: 0,
[DiscreteFrequency.F63]: 0,
[DiscreteFrequency.F80]: 0,
[DiscreteFrequency.F100]: 0,
[DiscreteFrequency.F125]: 0,
[DiscreteFrequency.F160]: 0,
[DiscreteFrequency.F200]: 0,
[DiscreteFrequency.F250]: 0,
[DiscreteFrequency.F315]: 0,
[DiscreteFrequency.F400]: 0,
[DiscreteFrequency.F500]: 0,
[DiscreteFrequency.F630]: 0,
[DiscreteFrequency.F800]: 0,
[DiscreteFrequency.F1000]: 0,
[DiscreteFrequency.F1250]: 0,
[DiscreteFrequency.F1600]: 0,
[DiscreteFrequency.F2000]: 0,
[DiscreteFrequency.F2500]: 0,
[DiscreteFrequency.F3150]: 0,
[DiscreteFrequency.F4000]: 0,
[DiscreteFrequency.F5000]: 0,
[DiscreteFrequency.F6300]: 0,
[DiscreteFrequency.F8000]: 0,
[DiscreteFrequency.F10000]: 0,
[DiscreteFrequency.F12500]: 0,
[DiscreteFrequency.F16000]: 0,
[DiscreteFrequency.F20000]: 0,
},
bypass: false,
});
const createDefaultPEQ = (): PEQ => ({
gain: 0,
frequency: 1000,
q: 1,
filter_type: PEQFilter.Peak,
bypass: false,
});
const createDefaultPEQChain = (): PEQChain => ({
bands: Object.fromEntries(
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
) as Record<PEQBand, PEQ>,
bypass: false,
});
const createDefaultCrossover = (): Crossover => ({
frequency: 20000,
slope: CrossoverFilter.Bypass,
});
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
high_pass: createDefaultCrossover(),
low_pass: createDefaultCrossover(),
});
const createDefaultInputChannelState = (name: string): InputChannelState => ({
name,
gain: 0,
phase_inverted: false,
delay: 0,
gate: createDefaultGate(),
geq: createDefaultGraphicEQ(),
peq_chain: createDefaultPEQChain(),
crossover: createDefaultCrossoverFilters(),
linked_channels: [],
mute: false,
});
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [InputChannel.InA],
gains: {
[InputChannel.InA]: 0,
[InputChannel.InB]: 0,
[InputChannel.InC]: 0,
[InputChannel.InD]: 0,
},
},
crossover: createDefaultCrossoverFilters(),
peq_chain: createDefaultPEQChain(),
compressor: {
threshold: -90,
ratio: Ratio.Ratio1_1,
attack: 1,
release: 10,
knee: 0,
},
limiter: {
threshold: -90,
attack: 1,
release: 10,
},
gain: 0,
phase_inverted: false,
delay: 0,
linked_channels: [],
mute: false,
});
export const defaultDSPState: DSPState = {
name: 'Default',
flags: {
is_locked: false,
},
presets: {
current_index: 0,
names: Array(20).fill('Default'),
modified: Array(20).fill(false),
},
current_config: {
input_source: {
type: 'Analog',
frequency: '48kHz',
},
delay_unit: DelayUnit.Millisecond,
input_states: {
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
[InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'),
},
output_states: {
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
},
},
};
const createDefaultMeter = (): Meter => ({
rms: -60,
peak: -60,
clip: false,
});
export const defaultMeters: Meters = {
channels: Array.from({ length: 12 }, createDefaultMeter),
};
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
-144
View File
@@ -1,144 +0,0 @@
export const GEQ_BAND_HZ = [
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
] as const;
/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */
export function geqPeakResponseDb(
freq: number,
bandFreq: number,
gainDb: number,
q: number
): number {
if (gainDb === 0) return 0;
const ratio = freq / bandFreq - bandFreq / freq;
return gainDb / (1 + (q * ratio) ** 2);
}
/** Sum of all bands' contributions at a given frequency. */
export function geqTotalCurveDb(
freq: number,
values: number[],
bandHz: readonly number[] = GEQ_BAND_HZ,
q: number
): number {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += geqPeakResponseDb(freq, bandHz[i], values[i], q);
}
return sum;
}
/** Log-spaced sample points from xMin to xMax, for a pixel-smooth curve on a log x-axis. */
export function logSpace(xMin: number, xMax: number, steps: number): number[] {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
const out: number[] = [];
for (let s = 0; s <= steps; s++) {
out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps));
}
return out;
}
export type GeqCurveColors = {
strokeColor: string;
fillAbove: string;
fillAboveFade: string;
fillBelowFade: string;
fillBelow: string;
};
export const DEFAULT_GEQ_CURVE_COLORS: GeqCurveColors = {
strokeColor: '#e5e7eb',
fillAbove: 'rgba(74, 222, 128, 0.35)',
fillAboveFade: 'rgba(74, 222, 128, 0.08)',
fillBelowFade: 'rgba(248, 113, 113, 0.08)',
fillBelow: 'rgba(248, 113, 113, 0.35)',
};
export type GeqCurveOptions = {
values: number[];
bandHz?: readonly number[];
q: number;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
steps?: number;
colors?: GeqCurveColors;
lineWidth?: number;
dimmed?: boolean;
};
/**
* 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,
q,
xMin,
xMax,
yMin,
yMax,
steps = 500,
colors = DEFAULT_GEQ_CURVE_COLORS,
lineWidth = 2,
dimmed = false,
}: GeqCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const freqs = logSpace(xMin, xMax, steps);
const points = freqs.map((freq) => {
const gain = geqTotalCurveDb(freq, values, bandHz, q);
return toPx(freq, gain);
});
ctx.save();
if (dimmed) ctx.globalAlpha = 0.25; // NEW: fade everything drawn below
// Fill area under the curve down to 0 dB
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
const [xEndPx, zeroYPx] = toPx(xMax, 0);
const [xStartPx] = toPx(xMin, 0);
ctx.lineTo(xEndPx, zeroYPx);
ctx.lineTo(xStartPx, zeroYPx);
ctx.closePath();
const [, topPx] = toPx(xMin, yMax);
const [, bottomPx] = toPx(xMin, yMin);
const gradient = ctx.createLinearGradient(0, topPx, 0, bottomPx);
gradient.addColorStop(0, colors.fillAbove);
gradient.addColorStop(0.5, colors.fillAboveFade);
gradient.addColorStop(0.5, colors.fillBelowFade);
gradient.addColorStop(1, colors.fillBelow);
ctx.fillStyle = gradient;
ctx.fill();
// Stroke the curve on top
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
ctx.strokeStyle = colors.strokeColor;
ctx.lineWidth = lineWidth;
if (dimmed) ctx.setLineDash([6, 4]); // dashed = "not actually applied"
ctx.stroke();
ctx.restore();
};
}
+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);
}
@@ -0,0 +1,194 @@
import { CrossoverFilter } from '../../types';
import {
lp6ResponseDb,
hp6ResponseDb,
lp12ResponseDb,
hp12ResponseDb,
lp6PhaseDeg,
hp6PhaseDeg,
lp12PhaseDeg,
hp12PhaseDeg,
} from './biquad';
import { CurvePoint } from './curve';
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);
}
/**
* Computes crossover magnitude points over a caller-supplied frequency
* grid. Pass the same `freqs` array you use for GEQ/PEQ to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getCrossoverCurvePoints({
freqs,
highPass,
lowPass,
}: {
freqs: number[];
highPass?: CrossoverSpec;
lowPass?: CrossoverSpec;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: crossoverPairMagnitudeDb(highPass, lowPass, freq),
}));
}
/** Computes crossover phase points over a caller-supplied frequency grid. */
export function getCrossoverPhasePoints({
freqs,
highPass,
lowPass,
}: {
freqs: number[];
highPass?: CrossoverSpec;
lowPass?: CrossoverSpec;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: crossoverPairPhaseDeg(highPass, lowPass, freq),
}));
}
+100
View File
@@ -0,0 +1,100 @@
export function logSpace(xMin: number, xMax: number, steps: number): number[] {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
const out: number[] = [];
for (let s = 0; s <= steps; s++) {
out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps));
}
return out;
}
export type CurvePoint = { x: number; y: number };
export type CurveColors = {
strokeColor: string;
fillAbove: string;
fillAboveFade: string;
fillBelowFade: string;
fillBelow: string;
};
export const DEFAULT_CURVE_COLORS: CurveColors = {
strokeColor: '#e5e7eb',
fillAbove: 'rgba(74, 222, 128, 0.35)',
fillAboveFade: 'rgba(74, 222, 128, 0.08)',
fillBelowFade: 'rgba(248, 113, 113, 0.08)',
fillBelow: 'rgba(248, 113, 113, 0.35)',
};
export type CurveStyle = {
strokeColor?: string;
fillColors?: CurveColors;
/** Draw the area fill under/around the curve. Defaults to true. */
fill?: boolean;
lineWidth?: number;
dimmed?: boolean;
};
/** A single polyline ready to be handed to `drawCurves`. */
export type Curve = {
points: CurvePoint[];
style?: CurveStyle;
};
export function sumCurvePoints(pointArrays: CurvePoint[][]): CurvePoint[] {
if (pointArrays.length === 0) return [];
const length = pointArrays[0].length;
const result: CurvePoint[] = new Array(length);
for (let i = 0; i < length; i++) {
let y = 0;
for (const points of pointArrays) {
y += points[i]?.y ?? 0;
}
result[i] = { x: pointArrays[0][i].x, y };
}
return result;
}
export function sumPhaseCurvePoints(
pointArrays: CurvePoint[][]
): CurvePoint[] {
if (pointArrays.length === 0) return [];
const length = pointArrays[0].length;
const result: CurvePoint[] = new Array(length);
let previousPhase = 0;
for (let i = 0; i < length; i++) {
let phase = 0;
for (const points of pointArrays) {
phase += points[i]?.y ?? 0;
}
// Keep phase continuous while summing.
while (phase - previousPhase > 180) {
phase -= 360;
}
while (phase - previousPhase < -180) {
phase += 360;
}
// Wrap to [-180, 180].
const wrapped =
((((phase + 180) % 360) + 360) % 360) - 180;
result[i] = {
x: pointArrays[0][i].x,
y: wrapped,
};
previousPhase = phase;
}
return result;
}
+104
View File
@@ -0,0 +1,104 @@
import {
CurvePoint,
CurveStyle,
Curve,
DEFAULT_CURVE_COLORS,
sumCurvePoints,
} from './curve';
export { sumCurvePoints };
type ToPx = (x: number, y: number) => [number, number];
type CanvasCtx = CanvasRenderingContext2D;
const DEFAULT_STYLE: Required<Omit<CurveStyle, never>> = {
strokeColor: DEFAULT_CURVE_COLORS.strokeColor,
fillColors: DEFAULT_CURVE_COLORS,
fill: true,
lineWidth: 2,
dimmed: false,
};
function drawSingleCurve(
ctx: CanvasCtx,
toPx: ToPx,
points: CurvePoint[],
xMin: number,
xMax: number,
yMin: number,
yMax: number,
style: Required<CurveStyle>
) {
if (points.length < 2) return;
ctx.save();
if (style.dimmed) {
ctx.globalAlpha = 0.25;
ctx.setLineDash([6, 4]);
}
if (style.fill) {
ctx.beginPath();
points.forEach(({ x, y }, i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
const [xEnd, zeroY] = toPx(xMax, 0);
const [xStart] = toPx(xMin, 0);
ctx.lineTo(xEnd, zeroY);
ctx.lineTo(xStart, zeroY);
ctx.closePath();
const [, topY] = toPx(xMin, yMax);
const [, bottomY] = toPx(xMin, yMin);
const gradient = ctx.createLinearGradient(0, topY, 0, bottomY);
gradient.addColorStop(0, style.fillColors.fillAbove);
gradient.addColorStop(0.5, style.fillColors.fillAboveFade);
gradient.addColorStop(0.5, style.fillColors.fillBelowFade);
gradient.addColorStop(1, style.fillColors.fillBelow);
ctx.fillStyle = gradient;
ctx.fill();
}
ctx.beginPath();
points.forEach(({ x, y }, i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.strokeStyle = style.strokeColor;
ctx.lineWidth = style.lineWidth;
ctx.stroke();
ctx.restore();
}
export type DrawCurvesParams = {
ctx: CanvasCtx;
toPx: ToPx;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
/** Each entry is drawn as its own independent polyline. To draw a
* combined curve, sum the point arrays with `sumCurvePoints` first
* and pass the result as a single entry. */
curves: Curve[];
};
/** Generic curve drawer — knows nothing about GEQ/PEQ/crossover. */
export function drawCurves({ ctx, toPx, xMin, xMax, yMin, yMax, curves }: DrawCurvesParams) {
for (const curve of curves) {
const style: Required<CurveStyle> = { ...DEFAULT_STYLE, ...curve.style };
drawSingleCurve(ctx, toPx, curve.points, xMin, xMax, yMin, yMax, style);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { CurvePoint } from './curve';
import { DISCRETE_FREQUENCIES } from '../../constants';
/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */
function geqPeakResponseDb(freq: number, bandFreq: number, gainDb: number, q: number): number {
if (gainDb === 0) return 0;
const ratio = freq / bandFreq - bandFreq / freq;
return gainDb / (1 + (q * ratio) ** 2);
}
/** Sum of all bands' contributions at a given frequency. */
function geqTotalCurveDb(
freq: number,
values: number[],
bandHz: readonly number[] = Object.values(DISCRETE_FREQUENCIES),
q: number
): number {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += geqPeakResponseDb(freq, bandHz[i], values[i], q);
}
return sum;
}
/**
* Computes GEQ magnitude points over a caller-supplied frequency grid.
* Pass the same `freqs` array you use for PEQ/crossover to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getGeqCurvePoints({
freqs,
values,
q,
bandHz = Object.values(DISCRETE_FREQUENCIES),
}: {
freqs: number[];
values: number[];
q: number;
bandHz?: readonly number[];
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: geqTotalCurveDb(freq, values, bandHz, q),
}));
}
+335
View File
@@ -0,0 +1,335 @@
import { PEQ, PEQBand, PEQFilter } from '../../types';
import {
DEFAULT_SAMPLE_RATE,
BiquadCoeffs,
biquadMagnitudeDb,
biquadPhaseDeg,
lp6ResponseDb,
hp6ResponseDb,
lp12ResponseDb,
hp12ResponseDb,
lp6PhaseDeg,
hp6PhaseDeg,
lp12PhaseDeg,
hp12PhaseDeg,
} from './biquad';
import { CurvePoint } from './curve';
// ----------- Magnitude 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);
}
/** 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. */
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;
}
/**
* Computes PEQ magnitude points over a caller-supplied frequency grid.
* Pass the same `freqs` array you use for GEQ/crossover to keep every
* curve on a shared x-grid (required for `sumCurvePoints`).
*/
export function getPeqCurvePoints({
freqs,
bands,
sampleRate = DEFAULT_SAMPLE_RATE,
}: {
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalCurveDb(freq, bands, sampleRate),
}));
}
// ----------- 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 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 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;
}
}
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;
}
export function getPeqPhasePoints({
freqs,
bands,
sampleRate = DEFAULT_SAMPLE_RATE,
}: {
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalPhaseDeg(freq, bands, sampleRate),
}));
}