Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a99b7173 | ||
|
|
75ac349790 | ||
|
|
21728b5e6d | ||
|
|
d6d0215e09 | ||
|
|
a0c9fbb674 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
PEQBand.B3,
|
|
||||||
PEQBand.B4,
|
|
||||||
PEQBand.B5,
|
|
||||||
PEQBand.B6,
|
|
||||||
PEQBand.B7,
|
|
||||||
PEQBand.B8,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types
|
function GraphViewToggle({
|
||||||
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [
|
view,
|
||||||
{ value: PEQFilter.Peak, label: 'Peak' },
|
onChange,
|
||||||
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
|
}: {
|
||||||
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
|
view: GraphView;
|
||||||
];
|
onChange: (v: GraphView) => void;
|
||||||
|
}) {
|
||||||
|
const options: { value: GraphView; label: string }[] = [
|
||||||
|
{ value: 'both', label: 'Both' },
|
||||||
|
{ value: 'magnitude', label: 'Mag' },
|
||||||
|
{ value: 'phase', label: 'Phase' },
|
||||||
|
];
|
||||||
|
|
||||||
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
|
return (
|
||||||
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
|
<div className="input-graph-view-toggle">
|
||||||
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
|
{options.map((opt) => (
|
||||||
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
|
<button
|
||||||
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
|
key={opt.value}
|
||||||
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
|
type="button"
|
||||||
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
|
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
|
||||||
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
|
onClick={() => onChange(opt.value)}
|
||||||
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
|
>
|
||||||
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
|
{opt.label}
|
||||||
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
|
</button>
|
||||||
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
|
))}
|
||||||
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
|
</div>
|
||||||
];
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const PEQ_SLIDER_PARAMS = [
|
function InputActions({
|
||||||
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
|
bypass,
|
||||||
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
|
resetting,
|
||||||
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
|
onBypass,
|
||||||
] as const;
|
onReset,
|
||||||
|
}: {
|
||||||
|
bypass: boolean;
|
||||||
|
resetting: boolean;
|
||||||
|
onBypass: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="geq-channel-selector">
|
||||||
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className={bypass ? 'active-warning' : ''}
|
||||||
|
onClick={onBypass}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '3.5cqh',
|
||||||
|
paddingTop: '2cqh',
|
||||||
|
paddingBottom: '2cqh',
|
||||||
|
minHeight: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bypass ? 'Bypassed' : 'Bypass'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
type SetPEQFn = (
|
<Button
|
||||||
channel: Channel,
|
variant="toggle"
|
||||||
band: PEQBand,
|
onClick={onReset}
|
||||||
|
disabled={resetting}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '3.5cqh',
|
||||||
|
paddingTop: '2cqh',
|
||||||
|
paddingBottom: '2cqh',
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resetting ? 'Resetting…' : 'Reset'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeqPanelSliders({
|
||||||
|
peq,
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user