Compare commits
2
Commits
eb751f9404
...
a28dd890dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28dd890dd | ||
|
|
00adc4df53 |
@@ -17,6 +17,8 @@
|
|||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
fontSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
@@ -15,7 +14,6 @@ export default function Button({
|
|||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
disabled,
|
disabled,
|
||||||
fontSize = 13,
|
|
||||||
type = 'button',
|
type = 'button',
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
@@ -26,7 +24,6 @@ export default function Button({
|
|||||||
className={`btn btn-${variant} ${className}`}
|
className={`btn btn-${variant} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${fontSize}px`,
|
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.channel-group {
|
.channel-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
/* flex: 1 1 0; */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
.choice-stepper {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 3cqh;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5ch;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrows {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5cqh;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1cqh;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: 4.5cqh;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 1cqh;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Button from '../Button/Button';
|
||||||
|
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||||
|
import './ChoiceStepper.css';
|
||||||
|
|
||||||
|
type ChoiceStepperProps<T> = {
|
||||||
|
value: T;
|
||||||
|
values: readonly T[];
|
||||||
|
formatLabel: (value: T) => string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChoiceStepper<T>({ value, values, formatLabel, onChange }: ChoiceStepperProps<T>) {
|
||||||
|
const index = values.indexOf(value);
|
||||||
|
|
||||||
|
const canUp = index === -1 || index < values.length - 1;
|
||||||
|
const canDown = index > 0;
|
||||||
|
|
||||||
|
const [text, setText] = useState(formatLabel(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}, [value, formatLabel]);
|
||||||
|
|
||||||
|
const step = (direction: 1 | -1) => {
|
||||||
|
const currentIndex = index === -1 ? 0 : index;
|
||||||
|
|
||||||
|
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + direction));
|
||||||
|
|
||||||
|
if (nextIndex !== currentIndex) {
|
||||||
|
onChange(values[nextIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitText = () => {
|
||||||
|
const match = values.find(
|
||||||
|
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match !== undefined) {
|
||||||
|
onChange(match);
|
||||||
|
} else {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="choice-stepper">
|
||||||
|
<input
|
||||||
|
className="choice-stepper__input"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commitText}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
step(1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
step(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="choice-stepper__arrows">
|
||||||
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className="choice-stepper__arrow-btn"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
disabled={!canUp}
|
||||||
|
>
|
||||||
|
<TriangleUpIcon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className="choice-stepper__arrow-btn"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
disabled={!canDown}
|
||||||
|
>
|
||||||
|
<TriangleDownIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChoiceStepper;
|
||||||
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
||||||
|
|
||||||
export type SidebarItem = {
|
export type SidebarItem<T extends string = string> = {
|
||||||
id: string;
|
id: T;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -13,12 +13,12 @@ export type SidebarAppOption = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarButton({
|
function SidebarButton<T extends string>({
|
||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
item: SidebarItem;
|
item: SidebarItem<T>;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -218,7 +218,7 @@ function AppCombobox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export function Sidebar<T extends string>({
|
||||||
items,
|
items,
|
||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -230,9 +230,9 @@ export default function Sidebar({
|
|||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
items: SidebarItem[];
|
items: SidebarItem<T>[];
|
||||||
activeId: string | null;
|
activeId: T | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: T) => void;
|
||||||
appName: string;
|
appName: string;
|
||||||
appOptions?: SidebarAppOption[];
|
appOptions?: SidebarAppOption[];
|
||||||
selectedAppId?: string;
|
selectedAppId?: string;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.signal-flow {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__chain-line {
|
||||||
|
stroke: var(--border-hairline, #333);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage {
|
||||||
|
fill: var(--bg-void);
|
||||||
|
stroke: var(--border-hairline);
|
||||||
|
stroke-width: 1;
|
||||||
|
transition: fill 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage:hover {
|
||||||
|
fill: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
fill: var(--text-primary, #e5e7eb);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
|
||||||
|
import { DSP408 } from '../../types';
|
||||||
|
import {
|
||||||
|
INPUT_ORDER,
|
||||||
|
OUTPUT_ORDER,
|
||||||
|
MATRIX_GAIN_MIN,
|
||||||
|
MATRIX_GAIN_MAX,
|
||||||
|
MATRIX_GAIN_STEP,
|
||||||
|
} from '../../features/dsp408/constants';
|
||||||
|
import { SidebarId } from '../../features/dsp408/constants';
|
||||||
|
import './SignalFlowMatrix.css';
|
||||||
|
|
||||||
|
const INPUT_SIDEBAR: Record<InputChannel, SidebarId> = {
|
||||||
|
InA: 'ina',
|
||||||
|
InB: 'inb',
|
||||||
|
InC: 'inc',
|
||||||
|
InD: 'ind',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUTPUT_SIDEBAR: Record<OutputChannel, SidebarId> = {
|
||||||
|
Out1: 'out1',
|
||||||
|
Out2: 'out2',
|
||||||
|
Out3: 'out3',
|
||||||
|
Out4: 'out4',
|
||||||
|
Out5: 'out5',
|
||||||
|
Out6: 'out6',
|
||||||
|
Out7: 'out7',
|
||||||
|
Out8: 'out8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_STAGES: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sidebar: SidebarId | 'input';
|
||||||
|
}[] = [
|
||||||
|
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
|
||||||
|
{ key: 'gate', label: 'GATE', sidebar: 'gate' },
|
||||||
|
{ key: 'geq', label: 'GEQ', sidebar: 'geq' },
|
||||||
|
{ key: 'peq', label: 'PEQ', sidebar: 'input' },
|
||||||
|
{ key: 'hplp', label: 'HP/LP', sidebar: 'input' },
|
||||||
|
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OUTPUT_STAGES: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sidebar: SidebarId | 'output';
|
||||||
|
}[] = [
|
||||||
|
{ key: 'xover', label: 'XOVER', sidebar: 'output' },
|
||||||
|
{ key: 'peq', label: 'PEQ', sidebar: 'output' },
|
||||||
|
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
|
||||||
|
{ key: 'compressor', label: 'COMP', sidebar: 'compressor' },
|
||||||
|
{ key: 'limiter', label: 'LIMIT', sidebar: 'limiter' },
|
||||||
|
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
|
||||||
|
{ key: 'mute', label: 'MUTE', sidebar: 'output' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INPUT_COLORS = [
|
||||||
|
'var(--accent-a, #60a5fa)',
|
||||||
|
'var(--accent-b, #34d399)',
|
||||||
|
'var(--accent-c, #f59e0b)',
|
||||||
|
'var(--accent-d, #f472b6)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const W = 1800;
|
||||||
|
const H = 520;
|
||||||
|
const MARGIN = 36;
|
||||||
|
const LEFT_CHAIN_W = 560;
|
||||||
|
const RIGHT_CHAIN_W = 620;
|
||||||
|
|
||||||
|
function getRoutes(dsp: DSP408, output: OutputChannel) {
|
||||||
|
return dsp.state.current_config.output_states[output].matrix_routes;
|
||||||
|
}
|
||||||
|
function isRouted(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
||||||
|
return getRoutes(dsp, output).connected.includes(input);
|
||||||
|
}
|
||||||
|
function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
||||||
|
return getRoutes(dsp, output).gains[input];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalFlowMatrix({
|
||||||
|
dsp,
|
||||||
|
setMatrixRouting,
|
||||||
|
setMatrixGain,
|
||||||
|
setActiveSidebar,
|
||||||
|
}: {
|
||||||
|
dsp: DSP408;
|
||||||
|
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
|
||||||
|
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
|
||||||
|
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
|
||||||
|
}) {
|
||||||
|
const nInputs = INPUT_ORDER.length;
|
||||||
|
const nOutputs = OUTPUT_ORDER.length;
|
||||||
|
|
||||||
|
const usableH = H - MARGIN * 2;
|
||||||
|
const inputRowH = usableH / nInputs;
|
||||||
|
const outputRowH = usableH / nOutputs;
|
||||||
|
|
||||||
|
const inputStageW = LEFT_CHAIN_W / (INPUT_STAGES.length + 1);
|
||||||
|
const outputStageW = RIGHT_CHAIN_W / (OUTPUT_STAGES.length + 1);
|
||||||
|
|
||||||
|
const leftStart = MARGIN;
|
||||||
|
const rightStart = W - MARGIN - RIGHT_CHAIN_W;
|
||||||
|
|
||||||
|
const inputCenterY = (i: number) => MARGIN + inputRowH * (i + 0.5);
|
||||||
|
const outputCenterY = (j: number) => MARGIN + outputRowH * (j + 0.5);
|
||||||
|
|
||||||
|
const inputNodeX = leftStart + LEFT_CHAIN_W;
|
||||||
|
const outputNodeX = rightStart;
|
||||||
|
|
||||||
|
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
||||||
|
const routes = getRoutes(dsp, output);
|
||||||
|
const next = routes.connected.includes(input)
|
||||||
|
? routes.connected.filter((c) => c !== input)
|
||||||
|
: [...routes.connected, input];
|
||||||
|
void setMatrixRouting(output, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheelGain = (e: React.WheelEvent, output: OutputChannel, input: InputChannel) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const current = getGain(dsp, output, input);
|
||||||
|
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
|
||||||
|
const next =
|
||||||
|
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
|
||||||
|
void setMatrixGain(input, output, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const routePairs = useMemo(() => {
|
||||||
|
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
|
||||||
|
OUTPUT_ORDER.forEach((output, j) => {
|
||||||
|
INPUT_ORDER.forEach((input, i) => pairs.push({ input, output, i, j }));
|
||||||
|
});
|
||||||
|
return pairs;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signal-flow">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
className="signal-flow__svg"
|
||||||
|
>
|
||||||
|
{routePairs.map(({ input, output, i, j }) => {
|
||||||
|
const connected = isRouted(dsp, output, input);
|
||||||
|
const y1 = inputCenterY(i);
|
||||||
|
const y2 = outputCenterY(j);
|
||||||
|
const cx1 = inputNodeX + (outputNodeX - inputNodeX) * 0.4;
|
||||||
|
const cx2 = inputNodeX + (outputNodeX - inputNodeX) * 0.6;
|
||||||
|
const path = `M ${inputNodeX} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${outputNodeX} ${y2}`;
|
||||||
|
const color = INPUT_COLORS[i % INPUT_COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${input}-${output}`}>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={connected ? color : 'var(--border-hairline, #333)'}
|
||||||
|
strokeWidth={connected ? 2.5 : 1}
|
||||||
|
strokeDasharray={connected ? undefined : '4 5'}
|
||||||
|
opacity={connected ? 0.9 : 0.35}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleToggle(output, input)}
|
||||||
|
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{INPUT_ORDER.map((input, i) => {
|
||||||
|
const y = inputCenterY(i);
|
||||||
|
const boxH = Math.min(inputRowH * 0.55, 40);
|
||||||
|
const name = dsp.state.current_config.input_states[input].name;
|
||||||
|
const boxes = [{ key: 'name', label: name, sidebar: 'input' as const }, ...INPUT_STAGES];
|
||||||
|
return (
|
||||||
|
<g key={input}>
|
||||||
|
{boxes.map((stage, si) => {
|
||||||
|
const x = leftStart + si * inputStageW;
|
||||||
|
const sidebarId = stage.sidebar === 'input' ? INPUT_SIDEBAR[input] : stage.sidebar;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={stage.key}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x + 3}
|
||||||
|
y={y - boxH / 2}
|
||||||
|
width={inputStageW - 6}
|
||||||
|
height={boxH}
|
||||||
|
rx={6}
|
||||||
|
className="signal-flow__stage"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + inputStageW / 2}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="signal-flow__stage-label"
|
||||||
|
>
|
||||||
|
{si === 0 ? name : stage.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{OUTPUT_ORDER.map((output, j) => {
|
||||||
|
const y = outputCenterY(j);
|
||||||
|
const boxH = Math.min(outputRowH * 0.55, 32);
|
||||||
|
const name = dsp.state.current_config.output_states[output].name;
|
||||||
|
const boxes = [
|
||||||
|
...OUTPUT_STAGES,
|
||||||
|
{ key: 'name', label: name, sidebar: 'output' as const },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<g key={output}>
|
||||||
|
{boxes.map((stage, si) => {
|
||||||
|
const x = rightStart + si * outputStageW;
|
||||||
|
const sidebarId =
|
||||||
|
stage.sidebar === 'output' ? OUTPUT_SIDEBAR[output] : stage.sidebar;
|
||||||
|
const isLast = si === boxes.length - 1;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={stage.key}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x + 3}
|
||||||
|
y={y - boxH / 2}
|
||||||
|
width={outputStageW - 6}
|
||||||
|
height={boxH}
|
||||||
|
rx={6}
|
||||||
|
className="signal-flow__stage"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + outputStageW / 2}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="signal-flow__stage-label"
|
||||||
|
>
|
||||||
|
{isLast ? name : stage.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignalFlowMatrix;
|
||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import './VerticalSlider.css';
|
import './VerticalSlider.css';
|
||||||
|
|
||||||
type StepFn = (value: number) => number;
|
type StepFn = (value: number) => number;
|
||||||
|
type QuantizeFn = (value: number) => number;
|
||||||
|
type Scale = 'linear' | 'log';
|
||||||
|
|
||||||
type VerticalSliderProps = {
|
type VerticalSliderProps = {
|
||||||
min: number;
|
min: number;
|
||||||
@@ -9,6 +11,17 @@ type VerticalSliderProps = {
|
|||||||
|
|
||||||
step?: StepFn;
|
step?: StepFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hard snap function applied after every value change (drag,
|
||||||
|
* keyboard, wheel, or typed input) — takes precedence over the built-in
|
||||||
|
* step-based rounding. Use this when the underlying hardware/DSP only
|
||||||
|
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw
|
||||||
|
* index mapped through a log curve) and the slider must always land on
|
||||||
|
* one of those exact values rather than an interpolated approximation.
|
||||||
|
* See makeLogQuantize().
|
||||||
|
*/
|
||||||
|
quantize?: QuantizeFn;
|
||||||
|
|
||||||
value: number;
|
value: number;
|
||||||
onChange?: (value: number) => Promise<boolean>;
|
onChange?: (value: number) => Promise<boolean>;
|
||||||
onInput?: (value: number) => void;
|
onInput?: (value: number) => void;
|
||||||
@@ -18,6 +31,9 @@ type VerticalSliderProps = {
|
|||||||
|
|
||||||
showTicks?: boolean;
|
showTicks?: boolean;
|
||||||
|
|
||||||
|
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */
|
||||||
|
scale?: Scale;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
@@ -37,16 +53,86 @@ function getDecimals(n: number) {
|
|||||||
return i === -1 ? 0 : s.length - i - 1;
|
return i === -1 ? 0 : s.length - i - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- scale helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
/** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */
|
||||||
|
function safeLogBound(n: number) {
|
||||||
|
return n > 0 ? n : Number.EPSILON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a real value to a 0..1 position, honoring the chosen scale. */
|
||||||
|
function valueToPercent(v: number, min: number, max: number, scale: Scale) {
|
||||||
|
if (scale === 'log') {
|
||||||
|
const logMin = Math.log(safeLogBound(min));
|
||||||
|
const logMax = Math.log(safeLogBound(max));
|
||||||
|
if (logMax === logMin) return 0;
|
||||||
|
const logV = Math.log(safeLogBound(v));
|
||||||
|
return (logV - logMin) / (logMax - logMin);
|
||||||
|
}
|
||||||
|
if (max === min) return 0;
|
||||||
|
return (v - min) / (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */
|
||||||
|
function percentToValue(percent: number, min: number, max: number, scale: Scale) {
|
||||||
|
if (scale === 'log') {
|
||||||
|
const logMin = Math.log(safeLogBound(min));
|
||||||
|
const logMax = Math.log(safeLogBound(max));
|
||||||
|
return Math.exp(logMin + percent * (logMax - logMin));
|
||||||
|
}
|
||||||
|
return min + percent * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a StepFn that yields roughly `points` evenly-spaced steps between
|
||||||
|
* `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300)
|
||||||
|
* gives ~300 points across a 20 Hz - 20 kHz range, with step size growing
|
||||||
|
* proportionally to the current value (fine resolution at low frequencies,
|
||||||
|
* coarser at high ones — matches how the ear perceives frequency).
|
||||||
|
*
|
||||||
|
* This is an approximation used to size keyboard/wheel nudges — it does NOT
|
||||||
|
* guarantee landing on the same exact grid a real device would produce.
|
||||||
|
* Pair it with `quantize` (see makeLogQuantize) when you need the slider to
|
||||||
|
* always land exactly on the device's own value set.
|
||||||
|
*/
|
||||||
|
export function makeLogStep(min: number, max: number, points: number): StepFn {
|
||||||
|
const ratio = Math.pow(max / min, 1 / Math.max(1, points));
|
||||||
|
const stepSize = ratio - 1;
|
||||||
|
return (value: number) => Math.max(min, value) * stepSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a QuantizeFn that snaps to the exact same log-spaced grid a device
|
||||||
|
* using a `raw` 0..points index would produce, e.g. for a frequency raw
|
||||||
|
* value stored as:
|
||||||
|
* raw = round(log(Hz / min) / log(max / min) * points)
|
||||||
|
* Hz = min * (max / min) ** (raw / points)
|
||||||
|
* makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set
|
||||||
|
* (raw 0..300), so the slider only ever settles on values the device itself
|
||||||
|
* would encode/decode to — no drift from an approximate step size.
|
||||||
|
*/
|
||||||
|
export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn {
|
||||||
|
const logRatio = Math.log(max / min);
|
||||||
|
return (value: number) => {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const raw = Math.round((Math.log(v / min) / logRatio) * points);
|
||||||
|
const clampedRaw = Math.min(points, Math.max(0, raw));
|
||||||
|
return min * Math.pow(max / min, clampedRaw / points);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function VerticalSlider({
|
export default function VerticalSlider({
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step = defaultStep,
|
step = defaultStep,
|
||||||
|
quantize,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
unit = '',
|
unit = '',
|
||||||
formatValue,
|
formatValue,
|
||||||
showTicks = true,
|
showTicks = true,
|
||||||
|
scale = 'linear',
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
}: VerticalSliderProps) {
|
}: VerticalSliderProps) {
|
||||||
@@ -60,10 +146,25 @@ export default function VerticalSlider({
|
|||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const thumbRef = useRef<HTMLDivElement>(null);
|
const thumbRef = useRef<HTMLDivElement>(null);
|
||||||
const keyboardEditing = useRef(false);
|
const keyboardEditing = useRef(false);
|
||||||
|
const wheelValue = useRef<number | null>(null);
|
||||||
|
const wheelEditing = useRef(false);
|
||||||
|
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)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dragging.current || keyboardEditing.current || editingText !== null) {
|
if (
|
||||||
|
dragging.current ||
|
||||||
|
keyboardEditing.current ||
|
||||||
|
wheelEditing.current ||
|
||||||
|
editingText !== null
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDisplayValue(value);
|
setDisplayValue(value);
|
||||||
@@ -99,6 +200,11 @@ export default function VerticalSlider({
|
|||||||
|
|
||||||
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 (quantize) {
|
||||||
|
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
const currentStep = step(clamped);
|
const currentStep = step(clamped);
|
||||||
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
|
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
|
||||||
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
|
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
|
||||||
@@ -119,7 +225,11 @@ export default function VerticalSlider({
|
|||||||
keyboardEditing.current = true;
|
keyboardEditing.current = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const base = keyboardValue.current ?? value;
|
// Base off displayValue (our own last-known-good local state), not the
|
||||||
|
// `value` prop: if onChange's round trip through the parent/audio engine
|
||||||
|
// hasn't landed yet, `value` can still be stale, which made repeated
|
||||||
|
// key presses appear to snap back to the pre-press position.
|
||||||
|
const base = keyboardValue.current ?? displayValue;
|
||||||
const currentStep = step(base);
|
const currentStep = step(base);
|
||||||
|
|
||||||
let next = base;
|
let next = base;
|
||||||
@@ -147,11 +257,65 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
wheelEditing.current = true;
|
||||||
|
|
||||||
|
const base = wheelValue.current ?? displayValue;
|
||||||
|
const currentStep = step(base);
|
||||||
|
// deltaY < 0 means scrolling up/away from the user — treat that as
|
||||||
|
// increasing the value, matching the vertical slider's "up = more".
|
||||||
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
|
||||||
|
const next = normalizeValue(base + direction * currentStep);
|
||||||
|
wheelValue.current = next;
|
||||||
|
updateValue(next);
|
||||||
|
|
||||||
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
||||||
|
wheelCommitTimeout.current = setTimeout(() => {
|
||||||
|
void commitWheel();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitWheel() {
|
||||||
|
if (wheelValue.current == null) return;
|
||||||
|
|
||||||
|
const next = wheelValue.current;
|
||||||
|
const success = await onChange?.(next);
|
||||||
|
|
||||||
|
wheelEditing.current = false;
|
||||||
|
wheelValue.current = null;
|
||||||
|
|
||||||
|
if (success === false) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sliderRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// React attaches onWheel passively by default, so preventDefault()
|
||||||
|
// wouldn't stop the page from scrolling underneath the slider — attach
|
||||||
|
// natively with { passive: false } instead.
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function valueFromPointer(clientY: number) {
|
function valueFromPointer(clientY: number) {
|
||||||
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 = min + percent * (max - min);
|
const raw = percentToValue(percent, min, max, scale);
|
||||||
updateValue(raw);
|
updateValue(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +376,7 @@ export default function VerticalSlider({
|
|||||||
setEditingText(null);
|
setEditingText(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const percent = (displayValue - min) / (max - min);
|
const percent = valueToPercent(displayValue, min, max, scale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
||||||
|
|||||||
@@ -126,87 +126,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-stepper {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 3cqh;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
color: var(--accent-brand);
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.5ch;
|
|
||||||
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__input:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__arrows {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5cqh;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 1cqh;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__arrow-btn {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
height: 4.5cqh;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
padding: 0;
|
|
||||||
color: var(--accent-brand);
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 1cqh;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__arrow-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-stepper__arrow-btn svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compressor-graph__channel {
|
.compressor-graph__channel {
|
||||||
font-size: var(--font-xs);
|
font-size: var(--font-xs);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||||
import { OutputChannel, Ratio } from '../../types';
|
import { OutputChannel, Ratio } from '../../types';
|
||||||
import { DSP408 as DSP } from '../../../../types';
|
import { DSP408 as DSP } from '../../../../types';
|
||||||
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 MeterBar from '../../../../components/MeterBar/MeterBar';
|
import MeterBar from '../../../../components/MeterBar/MeterBar';
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { TriangleUpIcon, TriangleDownIcon } from '../../../../assets/icons/Triangle';
|
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||||
import './CompressorSection.css';
|
import './CompressorSection.css';
|
||||||
|
|
||||||
// Enum order defines stepper order (1:1 -> Limit).
|
// Enum order defines stepper order (1:1 -> Limit).
|
||||||
@@ -109,85 +108,6 @@ type SetCompressorFn = (
|
|||||||
threshold: number
|
threshold: number
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
function ChoiceStepper<T>({
|
|
||||||
value,
|
|
||||||
values,
|
|
||||||
formatLabel,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: T;
|
|
||||||
values: readonly T[];
|
|
||||||
formatLabel: (value: T) => string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
}) {
|
|
||||||
const index = values.indexOf(value);
|
|
||||||
const canUp = index === -1 || index < values.length - 1;
|
|
||||||
const canDown = index > 0;
|
|
||||||
|
|
||||||
const [text, setText] = useState(formatLabel(value));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setText(formatLabel(value));
|
|
||||||
}, [value, formatLabel]);
|
|
||||||
|
|
||||||
const step = (dir: 1 | -1) => {
|
|
||||||
const currentIndex = index === -1 ? 0 : index;
|
|
||||||
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + dir));
|
|
||||||
if (nextIndex !== currentIndex) onChange(values[nextIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitText = () => {
|
|
||||||
const match = values.find(
|
|
||||||
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
|
||||||
);
|
|
||||||
if (match !== undefined) {
|
|
||||||
onChange(match);
|
|
||||||
} else {
|
|
||||||
setText(formatLabel(value));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="choice-stepper">
|
|
||||||
<input
|
|
||||||
className="choice-stepper__input"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onBlur={commitText}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur();
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
step(1);
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
step(-1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="choice-stepper__arrows">
|
|
||||||
<Button
|
|
||||||
variant="toggle"
|
|
||||||
className="choice-stepper__arrow-btn"
|
|
||||||
onClick={() => step(1)}
|
|
||||||
disabled={!canUp}
|
|
||||||
>
|
|
||||||
<TriangleUpIcon />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="toggle"
|
|
||||||
className="choice-stepper__arrow-btn"
|
|
||||||
onClick={() => step(-1)}
|
|
||||||
disabled={!canDown}
|
|
||||||
>
|
|
||||||
<TriangleDownIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompressorPanel({
|
function CompressorPanel({
|
||||||
channel,
|
channel,
|
||||||
dsp,
|
dsp,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DSP } from '../../../../types';
|
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
|
||||||
import Sidebar from '../../../../components/Sidebar/Sidebar';
|
|
||||||
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
||||||
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||||
import GainSection from '../GainSection/GainSection';
|
import GainSection from '../GainSection/GainSection';
|
||||||
@@ -14,19 +13,19 @@ import MatrixSection from '../Matrixsection/Matrixsection';
|
|||||||
import GeqSection from '../GeqSection/GeqSection';
|
import GeqSection from '../GeqSection/GeqSection';
|
||||||
import InputSection from '../InputSection/InputSection';
|
import InputSection from '../InputSection/InputSection';
|
||||||
import { DSP408Api } from '../../dsp408';
|
import { DSP408Api } from '../../dsp408';
|
||||||
|
import { DSP408 } from '../../../../types';
|
||||||
|
import { SidebarId } from '../../constants';
|
||||||
import './DSP408Panel.css';
|
import './DSP408Panel.css';
|
||||||
|
|
||||||
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
|
|
||||||
|
|
||||||
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||||
const [activeSidebar, setActiveSidebar] = useState('overview');
|
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||||||
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
|
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
|
||||||
const [presetLoading, setPresetLoading] = useState(false);
|
const [presetLoading, setPresetLoading] = useState(false);
|
||||||
const inputStates = dsp.state?.current_config.input_states;
|
const inputStates = dsp.state?.current_config.input_states;
|
||||||
const outputStates = dsp.state?.current_config.output_states;
|
const outputStates = dsp.state?.current_config.output_states;
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems: SidebarItem<SidebarId>[] = [
|
||||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||||
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
||||||
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
||||||
@@ -140,12 +139,10 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
<InputSection
|
<InputSection
|
||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
channel={InputChannel.InA}
|
channel={InputChannel.InA}
|
||||||
set_low_pass={api.setLowPass}
|
setLowPass={api.setLowPass}
|
||||||
set_high_pass={api.setHighPass}
|
setHighPass={api.setHighPass}
|
||||||
setPeq={api.setPeqBand}
|
setPeq={api.setPeqBand}
|
||||||
setGain={api.setGain}
|
setPeqBypass={api.setPEQBypass}
|
||||||
setMute={api.setMute}
|
|
||||||
setInverse={api.setInverse}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,6 +152,7 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
setMatrixRouting={api.setMatrixRouting}
|
setMatrixRouting={api.setMatrixRouting}
|
||||||
setMatrixGain={api.setMatrixGain}
|
setMatrixGain={api.setMatrixGain}
|
||||||
|
setActiveSidebar={setActiveSidebar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
justify-content: center; /* ← add this */
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -219,7 +220,7 @@
|
|||||||
.input-panel__cell-label {
|
.input-panel__cell-label {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 2.5cqh;
|
font-size: 3.2cqh;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -271,72 +272,92 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-meter-panel {
|
.peq-band-list {
|
||||||
container-type: size;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
position: relative;
|
min-height: 0;
|
||||||
height: 100%;
|
|
||||||
aspect-ratio: 1 / 2;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 1.5cqh;
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-void);
|
background: var(--bg-void);
|
||||||
|
padding: 1cqh;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
aspect-ratio: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-meter-panel__title {
|
.peq-band-list__row {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.4fr 1.2fr;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1cqh;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peq-band-list__row--header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 4cqh;
|
font-size: 4cqh;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
padding-top: 2cqh;
|
|
||||||
padding-bottom: 2cqh;
|
|
||||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
padding-top: 1cqh;
|
||||||
|
padding-bottom: 1cqh;
|
||||||
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-meter-panel__grid {
|
.peq-band-list__row--active {
|
||||||
flex: 1 1 auto;
|
background: var(--bg-panel);
|
||||||
min-height: 0;
|
border-radius: var(--radius-sm);
|
||||||
width: 100%;
|
|
||||||
padding: 2cqh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
/* slider/meter row gets the bulk of the height, button row stays compact */
|
|
||||||
grid-template-rows: 1fr auto;
|
|
||||||
gap: 1.5cqh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-meter-panel__cell {
|
.peq-band-list__cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-meter-panel__cell--slider,
|
|
||||||
.gain-meter-panel__cell--meter {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-meter-panel__cell--btn {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-meter-panel__cell--meter {
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: stretch;
|
justify-content: center;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peq-band-list__input,
|
||||||
|
.peq-band-list__select {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 2.2cqh;
|
||||||
|
padding: 0.5cqh;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// InputSection.tsx
|
// InputSection.tsx
|
||||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
import VerticalSlider, {
|
||||||
|
makeLogStep,
|
||||||
|
makeLogQuantize,
|
||||||
|
} from '../../../../components/VerticalSlider/VerticalSlider';
|
||||||
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
|
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
|
||||||
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
|
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
|
||||||
// if the actual enum differs.
|
// if the actual enum differs.
|
||||||
@@ -13,9 +16,23 @@ 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 MeterBar from '../../../../components/MeterBar/MeterBar';
|
||||||
|
|
||||||
const FREQ_MIN = 20;
|
const PEQ_BANDS: PEQBand[] = [
|
||||||
const FREQ_MAX = 20000;
|
PEQBand.B1,
|
||||||
const FREQ_STEP = 1;
|
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
|
||||||
|
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [
|
||||||
|
{ value: PEQFilter.Peak, label: 'Peak' },
|
||||||
|
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
|
||||||
|
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
|
||||||
|
];
|
||||||
|
|
||||||
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
|
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
|
||||||
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
|
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
|
||||||
@@ -112,6 +129,12 @@ function PeqPanelSliders({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
channel,
|
||||||
dsp,
|
dsp,
|
||||||
@@ -124,8 +147,7 @@ function CrossoverPanel({
|
|||||||
setCrossover: (
|
setCrossover: (
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
frequency: number,
|
frequency: number,
|
||||||
filter_type: CrossoverFilter,
|
filter_type: CrossoverFilter
|
||||||
bypass: boolean
|
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const channelState =
|
const channelState =
|
||||||
@@ -145,7 +167,7 @@ function CrossoverPanel({
|
|||||||
bypass?: boolean;
|
bypass?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const next = { frequency, filter_type, bypass, ...overrides };
|
const next = { frequency, filter_type, bypass, ...overrides };
|
||||||
return setCrossover(channel, next.frequency, next.filter_type, next.bypass);
|
return setCrossover(channel, next.frequency, next.filter_type);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,9 +175,11 @@ function CrossoverPanel({
|
|||||||
<div className="crossover-panel__title">{title}</div>
|
<div className="crossover-panel__title">{title}</div>
|
||||||
|
|
||||||
<VerticalSlider
|
<VerticalSlider
|
||||||
min={20}
|
min={FREQ_MIN}
|
||||||
max={20000}
|
max={FREQ_MAX}
|
||||||
step={() => 1}
|
scale="log"
|
||||||
|
step={FREQ_STEP}
|
||||||
|
quantize={FREQ_QUANTIZE}
|
||||||
unit=" Hz"
|
unit=" Hz"
|
||||||
value={frequency}
|
value={frequency}
|
||||||
onChange={(value) => commit({ frequency: value })}
|
onChange={(value) => commit({ frequency: value })}
|
||||||
@@ -194,99 +218,117 @@ function CrossoverPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GainMeterPanel({
|
function PeqBandList({
|
||||||
dsp,
|
|
||||||
channel,
|
channel,
|
||||||
title,
|
dsp,
|
||||||
meter,
|
activeBand,
|
||||||
setGain,
|
setActiveBand,
|
||||||
setMute,
|
setPEQ,
|
||||||
setInverse,
|
|
||||||
}: {
|
}: {
|
||||||
|
channel: InputChannel;
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
channel: Channel;
|
activeBand: PEQBand;
|
||||||
title: string;
|
setActiveBand: (band: PEQBand) => void;
|
||||||
meter: Meter;
|
setPEQ: SetPEQFn;
|
||||||
setGain: (channel: Channel, gain: number) => Promise<boolean>;
|
|
||||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
|
||||||
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
|
|
||||||
}) {
|
}) {
|
||||||
const channelState =
|
const inputState = dsp.state.current_config.input_states[channel];
|
||||||
'Input' in channel
|
|
||||||
? dsp.state?.current_config.input_states[channel.Input]
|
|
||||||
: dsp.state?.current_config.output_states[channel.Output];
|
|
||||||
|
|
||||||
const muted = channelState?.mute ?? false;
|
const commit = (band: PEQBand, overrides: Partial<PEQValues>) => {
|
||||||
const inverted = channelState?.phase_inverted ?? false;
|
const peq = inputState.peq_chain.bands[band] as PEQ;
|
||||||
const gain = channelState?.gain ?? 0;
|
const next = { ...peq, ...overrides };
|
||||||
|
return setPEQ(
|
||||||
const toggleMute = async () => {
|
{ Input: channel },
|
||||||
await setMute(channel, !muted);
|
band,
|
||||||
};
|
next.gain,
|
||||||
|
next.frequency,
|
||||||
const toggleInverse = async () => {
|
next.q,
|
||||||
await setInverse(channel, !inverted);
|
next.filter_type,
|
||||||
|
next.bypass
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gain-meter-panel">
|
<div className="peq-band-list">
|
||||||
<div className="gain-meter-panel__title">{title}</div>
|
<div className="peq-band-list__row peq-band-list__row--header">
|
||||||
|
<div className="peq-band-list__cell">Band</div>
|
||||||
|
<div className="peq-band-list__cell">Freq</div>
|
||||||
|
<div className="peq-band-list__cell">Q</div>
|
||||||
|
<div className="peq-band-list__cell">Gain</div>
|
||||||
|
<div className="peq-band-list__cell">Type</div>
|
||||||
|
<div className="peq-band-list__cell">Bypass</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="gain-meter-panel__grid">
|
{PEQ_BANDS.map((band, i) => {
|
||||||
<div className="gain-meter-panel__cell gain-meter-panel__cell--slider">
|
const peq = inputState.peq_chain.bands[band] as PEQ;
|
||||||
<VerticalSlider
|
const isActive = band === activeBand;
|
||||||
min={-60}
|
|
||||||
max={12}
|
return (
|
||||||
step={(v) => (v >= -20 ? 0.1 : 0.5)}
|
<div
|
||||||
unit=" dB"
|
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
||||||
value={gain}
|
key={band}
|
||||||
onChange={(value) => setGain(channel, value)}
|
>
|
||||||
style={{ width: '100%', height: '100%' }}
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className={isActive ? 'active' : ''}
|
||||||
|
onClick={() => setActiveBand(band)}
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{`B${i + 1}`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="peq-band-list__input"
|
||||||
|
type="number"
|
||||||
|
step={0.5}
|
||||||
|
min={19.7}
|
||||||
|
max={20160}
|
||||||
|
value={peq.frequency}
|
||||||
|
onChange={(e) => commit(band, { frequency: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="gain-meter-panel__cell gain-meter-panel__cell--meter">
|
<input
|
||||||
<MeterBar label="" meter={meter} />
|
className="peq-band-list__input"
|
||||||
</div>
|
type="number"
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={peq.q}
|
||||||
|
onChange={(e) => commit(band, { q: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="peq-band-list__input"
|
||||||
|
type="number"
|
||||||
|
step={0.1}
|
||||||
|
min={-12}
|
||||||
|
max={12}
|
||||||
|
value={peq.gain}
|
||||||
|
onChange={(e) => commit(band, { gain: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="peq-band-list__select"
|
||||||
|
value={peq.filter_type}
|
||||||
|
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter })}
|
||||||
|
>
|
||||||
|
{PEQ_FILTER_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
|
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
className={inverted ? 'active-warning' : ''}
|
className={peq.bypass ? 'active-warning' : ''}
|
||||||
onClick={toggleInverse}
|
onClick={() => commit(band, { bypass: !peq.bypass })}
|
||||||
style={{
|
style={{ minHeight: 0 }}
|
||||||
width: '100%',
|
|
||||||
paddingLeft: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
fontSize: '3.5cqh',
|
|
||||||
paddingTop: '1cqh',
|
|
||||||
paddingBottom: '1cqh',
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{inverted ? 'Inverted' : 'Invert'}
|
{peq.bypass ? 'Bypassed' : 'Bypass'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
|
})}
|
||||||
<Button
|
|
||||||
variant="toggle"
|
|
||||||
className={muted ? 'active-danger' : ''}
|
|
||||||
onClick={toggleMute}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
paddingLeft: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
fontSize: '3.5cqh',
|
|
||||||
paddingTop: '1cqh',
|
|
||||||
paddingBottom: '1cqh',
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{muted ? 'Muted' : 'Mute'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,23 +352,17 @@ type setPeqBandFn = (
|
|||||||
function InputSection({
|
function InputSection({
|
||||||
dsp,
|
dsp,
|
||||||
channel,
|
channel,
|
||||||
set_high_pass: setHighPass,
|
setHighPass,
|
||||||
set_low_pass,
|
setLowPass,
|
||||||
setPeq,
|
setPeq,
|
||||||
// setPeqBypass,
|
setPeqBypass,
|
||||||
setGain,
|
|
||||||
setMute,
|
|
||||||
setInverse,
|
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
channel: InputChannel;
|
channel: InputChannel;
|
||||||
set_high_pass: SetFilterFn;
|
setHighPass: SetFilterFn;
|
||||||
set_low_pass: SetFilterFn;
|
setLowPass: SetFilterFn;
|
||||||
setPeq: setPeqBandFn;
|
setPeq: setPeqBandFn;
|
||||||
// setPeqBypass;
|
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
|
||||||
setGain: (channel: Channel, gain: number) => Promise<boolean>;
|
|
||||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
|
||||||
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
|
|
||||||
}) {
|
}) {
|
||||||
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
||||||
|
|
||||||
@@ -408,7 +444,14 @@ function InputSection({
|
|||||||
<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">
|
||||||
<PeqPanelSliders channel={channel} dsp={dsp} band={PEQBand.B1} setPEQ={setPeq} />
|
<PeqBandList
|
||||||
|
channel={channel}
|
||||||
|
dsp={dsp}
|
||||||
|
activeBand={activeBand}
|
||||||
|
setActiveBand={setActiveBand}
|
||||||
|
setPEQ={setPeq}
|
||||||
|
/>
|
||||||
|
<PeqPanelSliders channel={channel} dsp={dsp} band={activeBand} setPEQ={setPeq} />
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
|
|
||||||
<ChannelGroup title="CrossOver">
|
<ChannelGroup title="CrossOver">
|
||||||
@@ -425,18 +468,6 @@ function InputSection({
|
|||||||
setCrossover={setHighPass}
|
setCrossover={setHighPass}
|
||||||
/>
|
/>
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
|
|
||||||
<ChannelGroup title="Gain">
|
|
||||||
<GainMeterPanel
|
|
||||||
dsp={dsp}
|
|
||||||
channel={{ Input: channel }}
|
|
||||||
title={dsp.state.current_config.input_states.InA.name}
|
|
||||||
meter={dsp.meters.channels[0]}
|
|
||||||
setGain={setGain}
|
|
||||||
setMute={setMute}
|
|
||||||
setInverse={setInverse}
|
|
||||||
/>
|
|
||||||
</ChannelGroup>
|
|
||||||
{/* <div className="input-control-panel__sliders">
|
{/* <div className="input-control-panel__sliders">
|
||||||
<div className="input-control-panel__cell input-control-panel__cell--high-pass">
|
<div className="input-control-panel__cell input-control-panel__cell--high-pass">
|
||||||
<VerticalSlider
|
<VerticalSlider
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* MatrixSection.css */
|
||||||
.matrix-section {
|
.matrix-section {
|
||||||
container-type: size;
|
container-type: size;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
height: var(--dsp-control-heigth);
|
height: var(--dsp-control-heigth);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -20,152 +21,73 @@
|
|||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Placeholder graph area (top) ---------- */
|
.matrix-panels {
|
||||||
/* Intentionally minimal for now — see chat for ideas on what belongs here. */
|
height: 100%;
|
||||||
|
|
||||||
.matrix-graph-section {
|
|
||||||
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
gap: var(--space-2);
|
||||||
gap: 1cqh;
|
overflow-x: auto;
|
||||||
padding: 0.5cqh;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-graph-panel {
|
.matrix-panel {
|
||||||
flex: 1 1 auto;
|
position: relative;
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
aspect-ratio: 0.6;
|
||||||
align-items: center;
|
flex: 0 0 auto;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
border: var(--border-width) dashed var(--border-hairline);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Matrix grid (bottom) ---------- */
|
|
||||||
|
|
||||||
.matrix-grid-panel {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
padding: var(--space-2);
|
justify-content: flex-start;
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-grid {
|
|
||||||
display: grid;
|
|
||||||
/* 1 corner cell + 1 column per input */
|
|
||||||
grid-template-columns: minmax(8ch, 12cqh) repeat(var(--matrix-input-count, 4), minmax(9cqh, 1fr));
|
|
||||||
/* 1 header row + 1 row per output */
|
|
||||||
grid-auto-rows: minmax(6cqh, 1fr);
|
|
||||||
|
|
||||||
gap: var(--border-width);
|
|
||||||
background: var(--border-hairline);
|
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
box-sizing: border-box;
|
||||||
|
background: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-grid__corner,
|
.matrix-panel__title {
|
||||||
.matrix-grid__col-header,
|
flex: 0 0 auto;
|
||||||
.matrix-grid__row-header {
|
width: 100%;
|
||||||
background: var(--bg-void);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: 2.5cqh;
|
font-size: 3cqh;
|
||||||
padding: 0.5cqh;
|
font-weight: var(--font-weight-semibold);
|
||||||
|
padding-top: 1.5cqh;
|
||||||
|
padding-bottom: 1.5cqh;
|
||||||
|
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-grid__row-header {
|
.matrix-panel__rows {
|
||||||
justify-content: flex-start;
|
flex: 1 1 auto;
|
||||||
padding-left: 1cqh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-cell {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
background: var(--bg-panel);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5cqh;
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
border: none;
|
display: grid;
|
||||||
cursor: pointer;
|
grid-template-rows: repeat(4, 1fr);
|
||||||
-webkit-user-select: none;
|
gap: 5cqh;
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
padding-block: 5cqh;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-cell:focus-visible {
|
.matrix-panel__row {
|
||||||
outline: 2px solid var(--accent-brand);
|
min-height: 0;
|
||||||
outline-offset: -2px;
|
min-width: 0;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3cqh;
|
||||||
|
padding-inline: 3cqh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-cell--off {
|
.matrix-flow {
|
||||||
color: var(--text-muted);
|
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
||||||
}
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
.matrix-cell--off .matrix-cell__dot {
|
padding: var(--space-2);
|
||||||
width: 0.8cqh;
|
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||||
height: 0.8cqh;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-cell--on {
|
|
||||||
background: color-mix(in srgb, var(--accent-brand) 14%, var(--bg-panel));
|
|
||||||
color: var(--accent-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-cell--dragging {
|
|
||||||
background: color-mix(in srgb, var(--accent-brand) 26%, var(--bg-panel));
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-cell__gain {
|
|
||||||
font-size: 2.6cqh;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-cell__link-icon {
|
|
||||||
width: 2.2cqh;
|
|
||||||
height: 2.2cqh;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +1,49 @@
|
|||||||
// MatrixSection.tsx
|
// MatrixSection.tsx
|
||||||
import { Fragment, useRef, useState } from 'react';
|
import { InputChannel, OutputChannel, MatrixRoutes } from '../../types';
|
||||||
import { InputChannel, OutputChannel } from '../../types';
|
|
||||||
import { DSP408 as DSP } from '../../../../types';
|
import { DSP408 as DSP } from '../../../../types';
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
|
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||||
|
import {
|
||||||
|
INPUT_ORDER,
|
||||||
|
OUTPUT_ORDER,
|
||||||
|
MATRIX_GAIN_MIN,
|
||||||
|
MATRIX_GAIN_MAX,
|
||||||
|
MATRIX_GAIN_STEP,
|
||||||
|
} from '../../constants';
|
||||||
|
import { SidebarId } from '../../constants';
|
||||||
|
import SignalFlowMatrix from '../../../../components/SignalFlowMatrix/SignalFlowMatrix';
|
||||||
import './MatrixSection.css';
|
import './MatrixSection.css';
|
||||||
|
|
||||||
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in
|
const GAIN_VALUES: number[] = (() => {
|
||||||
// dsp408State, e.g. In1..In4. Adjust the import/labels below if the real
|
const values: number[] = [];
|
||||||
// enum differs.
|
for (let v = MATRIX_GAIN_MIN; v <= MATRIX_GAIN_MAX + 1e-9; v += MATRIX_GAIN_STEP) {
|
||||||
const INPUTS: InputChannel[] = [
|
values.push(Math.round(v * 10) / 10);
|
||||||
InputChannel.InA,
|
}
|
||||||
InputChannel.InB,
|
return values;
|
||||||
InputChannel.InC,
|
})();
|
||||||
InputChannel.InD,
|
|
||||||
];
|
|
||||||
|
|
||||||
const OUTPUTS: OutputChannel[] = [
|
function getMatrixRoutes(dsp: DSP, output: OutputChannel): MatrixRoutes {
|
||||||
OutputChannel.Out1,
|
return dsp.state.current_config.output_states[output].matrix_routes;
|
||||||
OutputChannel.Out2,
|
|
||||||
OutputChannel.Out3,
|
|
||||||
OutputChannel.Out4,
|
|
||||||
OutputChannel.Out5,
|
|
||||||
OutputChannel.Out6,
|
|
||||||
OutputChannel.Out7,
|
|
||||||
OutputChannel.Out8,
|
|
||||||
];
|
|
||||||
|
|
||||||
const INPUT_LABEL: Record<InputChannel, string> = {
|
|
||||||
[InputChannel.InA]: 'In 1',
|
|
||||||
[InputChannel.InB]: 'In 2',
|
|
||||||
[InputChannel.InC]: 'In 3',
|
|
||||||
[InputChannel.InD]: 'In 4',
|
|
||||||
};
|
|
||||||
|
|
||||||
const GAIN_MIN = -60;
|
|
||||||
const GAIN_MAX = 12;
|
|
||||||
const GAIN_DEFAULT = 0; // gain a link is set to the moment it's first routed
|
|
||||||
const GAIN_STEP = 0.5; // wheel / arrow-key nudge
|
|
||||||
const DRAG_PX_PER_DB = 4; // vertical drag sensitivity
|
|
||||||
|
|
||||||
function clampGain(v: number) {
|
|
||||||
return Math.min(GAIN_MAX, Math.max(GAIN_MIN, v));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGain(v: number) {
|
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ASSUMPTION: each output tracks which inputs are currently routed to it
|
|
||||||
// (output_states[out].matrix_inputs: InputChannel[]) and the last-known gain
|
|
||||||
// per input (output_states[out].matrix_gains: Partial<Record<InputChannel,
|
|
||||||
// number>>, in dB, kept around even if the link is later removed so it can
|
|
||||||
// be restored). Adjust these two accessors if the real shape differs —
|
|
||||||
// everything else only depends on the two functions below.
|
|
||||||
type MatrixOutputState = {
|
|
||||||
matrix_inputs?: InputChannel[];
|
|
||||||
matrix_gains?: Partial<Record<InputChannel, number>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRouted(dsp: DSP, output: OutputChannel, input: InputChannel): boolean {
|
function isRouted(dsp: DSP, output: OutputChannel, input: InputChannel): boolean {
|
||||||
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
|
return getMatrixRoutes(dsp, output).connected.includes(input);
|
||||||
return state.matrix_inputs?.includes(input) ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number {
|
function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number {
|
||||||
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
|
return getMatrixRoutes(dsp, output).gains[input];
|
||||||
return state.matrix_gains?.[input] ?? GAIN_DEFAULT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
|
function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
|
||||||
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
|
return getMatrixRoutes(dsp, output).connected;
|
||||||
return state.matrix_inputs ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function MatrixCell({
|
|
||||||
output,
|
|
||||||
input,
|
|
||||||
connected,
|
|
||||||
gain,
|
|
||||||
onToggle,
|
|
||||||
onGainChange,
|
|
||||||
}: {
|
|
||||||
output: OutputChannel;
|
|
||||||
input: InputChannel;
|
|
||||||
connected: boolean;
|
|
||||||
gain: number;
|
|
||||||
onToggle: () => void;
|
|
||||||
onGainChange: (gain: number) => void;
|
|
||||||
}) {
|
|
||||||
const [liveGain, setLiveGain] = useState<number | null>(null);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const dragStart = useRef<{ y: number; base: number } | null>(null);
|
|
||||||
|
|
||||||
const displayGain = liveGain ?? gain;
|
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent) => {
|
|
||||||
if (!connected) return; // plain click on an off cell just routes it (see onClick)
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
dragStart.current = { y: e.clientY, base: gain };
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent) => {
|
|
||||||
if (!dragStart.current) return;
|
|
||||||
const deltaPx = dragStart.current.y - e.clientY; // up = louder
|
|
||||||
const next = clampGain(dragStart.current.base + deltaPx / DRAG_PX_PER_DB);
|
|
||||||
setLiveGain(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const endDrag = () => {
|
|
||||||
if (dragStart.current && liveGain !== null) {
|
|
||||||
onGainChange(liveGain);
|
|
||||||
}
|
|
||||||
dragStart.current = null;
|
|
||||||
setDragging(false);
|
|
||||||
setLiveGain(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
|
||||||
if (!connected) return;
|
|
||||||
e.preventDefault();
|
|
||||||
onGainChange(clampGain(gain + (e.deltaY < 0 ? GAIN_STEP : -GAIN_STEP)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggle();
|
|
||||||
} else if (connected && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
|
||||||
onGainChange(clampGain(gain + dir * GAIN_STEP));
|
|
||||||
} else if (connected && (e.key === 'Backspace' || e.key === 'Delete')) {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggle();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-pressed={connected}
|
|
||||||
aria-label={`${INPUT_LABEL[input]} to ${String(output)}${
|
|
||||||
connected ? `, ${formatGain(displayGain)} dB` : ', not routed'
|
|
||||||
}`}
|
|
||||||
className={[
|
|
||||||
'matrix-cell',
|
|
||||||
connected ? 'matrix-cell--on' : 'matrix-cell--off',
|
|
||||||
dragging ? 'matrix-cell--dragging' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={connected ? undefined : onToggle}
|
|
||||||
onDoubleClick={connected ? onToggle : undefined}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={endDrag}
|
|
||||||
onPointerCancel={endDrag}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
title={
|
|
||||||
connected
|
|
||||||
? `${formatGain(displayGain)} dB — drag/scroll to adjust, double-click to remove`
|
|
||||||
: 'Click to route'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{connected ? (
|
|
||||||
<span className="matrix-cell__gain">{formatGain(displayGain)}</span>
|
|
||||||
) : (
|
|
||||||
<span className="matrix-cell__dot" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatrixSection({
|
function MatrixSection({
|
||||||
dsp,
|
dsp,
|
||||||
setMatrixRouting,
|
setMatrixRouting,
|
||||||
setMatrixGain,
|
setMatrixGain,
|
||||||
|
setActiveSidebar,
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
|
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
|
||||||
@@ -186,21 +52,16 @@ function MatrixSection({
|
|||||||
outputChannel: OutputChannel,
|
outputChannel: OutputChannel,
|
||||||
gain: number
|
gain: number
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
|
||||||
}) {
|
}) {
|
||||||
// Toggling a cell sends the *whole* next input list for that output —
|
|
||||||
// setMatrixRouting replaces, it doesn't add/remove a single link.
|
|
||||||
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
||||||
const current = getRoutedInputs(dsp, output);
|
const current = getRoutedInputs(dsp, output);
|
||||||
const nowConnecting = !current.includes(input);
|
|
||||||
const next = nowConnecting ? [...current, input] : current.filter((i) => i !== input);
|
|
||||||
|
|
||||||
void setMatrixRouting(output, next).then((ok) => {
|
const isConnected = current.includes(input);
|
||||||
// Give a freshly-connected link a defined starting gain rather than
|
|
||||||
// leaving it at whatever stale value the backend last had for it.
|
const next = isConnected ? current.filter((channel) => channel !== input) : [...current, input];
|
||||||
if (ok && nowConnecting) {
|
|
||||||
void setMatrixGain(input, output, GAIN_DEFAULT);
|
void setMatrixRouting(output, next);
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
|
const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
|
||||||
@@ -209,46 +70,59 @@ function MatrixSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="matrix-graph-section">
|
<div className="matrix-flow">
|
||||||
<div className="matrix-graph-panel">Routing overview — see chat for ideas</div>
|
<SignalFlowMatrix
|
||||||
|
dsp={dsp}
|
||||||
|
setMatrixRouting={setMatrixRouting}
|
||||||
|
setMatrixGain={setMatrixGain}
|
||||||
|
setActiveSidebar={setActiveSidebar}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="matrix-section">
|
<div className="matrix-section">
|
||||||
<div
|
<div className="matrix-panels">
|
||||||
className="matrix-grid-panel"
|
<ChannelGroup title="Output Routing">
|
||||||
style={{ ['--matrix-input-count' as string]: INPUTS.length }}
|
{OUTPUT_ORDER.map((output) => (
|
||||||
>
|
<div className="matrix-panel" key={String(output)}>
|
||||||
<div className="matrix-grid">
|
<div className="matrix-panel__title">
|
||||||
<div className="matrix-grid__corner" />
|
|
||||||
{INPUTS.map((input) => (
|
|
||||||
<div className="matrix-grid__col-header" key={String(input)}>
|
|
||||||
{INPUT_LABEL[input]}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{OUTPUTS.map((output) => (
|
|
||||||
<Fragment key={String(output)}>
|
|
||||||
<div className="matrix-grid__row-header">
|
|
||||||
{dsp.state.current_config.output_states[output].name}
|
{dsp.state.current_config.output_states[output].name}
|
||||||
</div>
|
</div>
|
||||||
{INPUTS.map((input) => {
|
|
||||||
|
<div className="matrix-panel__rows">
|
||||||
|
{INPUT_ORDER.map((input) => {
|
||||||
const connected = isRouted(dsp, output, input);
|
const connected = isRouted(dsp, output, input);
|
||||||
|
const gain = getLinkGain(dsp, output, input);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MatrixCell
|
<div className="matrix-panel__row" key={String(input)}>
|
||||||
key={`${String(output)}-${String(input)}`}
|
<Button
|
||||||
output={output}
|
variant="toggle"
|
||||||
input={input}
|
className={connected ? 'active-info' : ''}
|
||||||
connected={connected}
|
style={{
|
||||||
gain={getLinkGain(dsp, output, input)}
|
fontSize: '4cqh',
|
||||||
onToggle={() => handleToggle(output, input)}
|
padding: 0,
|
||||||
onGainChange={(gain) => handleGainChange(output, input, gain)}
|
minHeight: 0,
|
||||||
|
marginTop: '2cqh',
|
||||||
|
marginBottom: '2cqh',
|
||||||
|
}}
|
||||||
|
onClick={() => handleToggle(output, input)}
|
||||||
|
>
|
||||||
|
{dsp.state.current_config.input_states[input].name}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ChoiceStepper
|
||||||
|
value={gain}
|
||||||
|
values={GAIN_VALUES}
|
||||||
|
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||||
|
onChange={(g) => handleGainChange(output, input, g)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</ChannelGroup>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DiscreteFrequency, InputChannel } from './types';
|
import { DiscreteFrequency, InputChannel, OutputChannel } from './types';
|
||||||
|
|
||||||
export const INPUT_ORDER: InputChannel[] = [
|
export const INPUT_ORDER: InputChannel[] = [
|
||||||
InputChannel.InA,
|
InputChannel.InA,
|
||||||
@@ -7,6 +7,17 @@ export const INPUT_ORDER: InputChannel[] = [
|
|||||||
InputChannel.InD,
|
InputChannel.InD,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const OUTPUT_ORDER: OutputChannel[] = [
|
||||||
|
OutputChannel.Out1,
|
||||||
|
OutputChannel.Out2,
|
||||||
|
OutputChannel.Out3,
|
||||||
|
OutputChannel.Out4,
|
||||||
|
OutputChannel.Out5,
|
||||||
|
OutputChannel.Out6,
|
||||||
|
OutputChannel.Out7,
|
||||||
|
OutputChannel.Out8,
|
||||||
|
];
|
||||||
|
|
||||||
export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
|
export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
|
||||||
[DiscreteFrequency.F20]: 20,
|
[DiscreteFrequency.F20]: 20,
|
||||||
[DiscreteFrequency.F25]: 25,
|
[DiscreteFrequency.F25]: 25,
|
||||||
@@ -82,8 +93,37 @@ 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;
|
||||||
|
|
||||||
|
// ------ Matrix constants ------
|
||||||
|
export const MATRIX_GAIN_MIN = -60;
|
||||||
|
export const MATRIX_GAIN_MAX = 0;
|
||||||
|
export const MATRIX_GAIN_STEP = 0.1;
|
||||||
|
|
||||||
// ------ 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;
|
||||||
export const GRAPH_Y_MIN = -21;
|
export const GRAPH_Y_MIN = -21;
|
||||||
export const GRAPH_Y_MAX = 21;
|
export const GRAPH_Y_MAX = 21;
|
||||||
|
|
||||||
|
// ------ App ------
|
||||||
|
export type SidebarId =
|
||||||
|
| 'overview'
|
||||||
|
| 'gain'
|
||||||
|
| 'gate'
|
||||||
|
| 'compressor'
|
||||||
|
| 'limiter'
|
||||||
|
| 'delay'
|
||||||
|
| 'matrix'
|
||||||
|
| 'geq'
|
||||||
|
| 'ina'
|
||||||
|
| 'inb'
|
||||||
|
| 'inc'
|
||||||
|
| 'ind'
|
||||||
|
| 'out1'
|
||||||
|
| 'out2'
|
||||||
|
| 'out3'
|
||||||
|
| 'out4'
|
||||||
|
| 'out5'
|
||||||
|
| 'out6'
|
||||||
|
| 'out7'
|
||||||
|
| 'out8'
|
||||||
|
| 'miscellaneous';
|
||||||
|
|||||||
@@ -952,7 +952,6 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
const cfg = d.state.current_config;
|
const cfg = d.state.current_config;
|
||||||
const outputState = cfg.output_states[outputChannel];
|
const outputState = cfg.output_states[outputChannel];
|
||||||
const inputIndex = Object.values(InputChannel).indexOf(inputChannel);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
@@ -966,9 +965,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
...outputState,
|
...outputState,
|
||||||
matrix_routes: {
|
matrix_routes: {
|
||||||
...outputState.matrix_routes,
|
...outputState.matrix_routes,
|
||||||
gains: outputState.matrix_routes.gains.map((g, i) =>
|
gains: {
|
||||||
i === inputIndex ? gain : g
|
...outputState.matrix_routes.gains,
|
||||||
),
|
[inputChannel]: gain,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -977,7 +977,6 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dsp.demo) {
|
if (dsp.demo) {
|
||||||
updateMatrixGain();
|
updateMatrixGain();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -106,15 +106,15 @@ export enum DiscreteFrequency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum PEQBand {
|
export enum PEQBand {
|
||||||
B1 = 1,
|
B1 = 'B1',
|
||||||
B2 = 2,
|
B2 = 'B2',
|
||||||
B3 = 3,
|
B3 = 'B3',
|
||||||
B4 = 4,
|
B4 = 'B4',
|
||||||
B5 = 5,
|
B5 = 'B5',
|
||||||
B6 = 6,
|
B6 = 'B6',
|
||||||
B7 = 7,
|
B7 = 'B7',
|
||||||
B8 = 8,
|
B8 = 'B8',
|
||||||
B9 = 9,
|
B9 = 'B9',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- STATE ----------------
|
// ---------------- STATE ----------------
|
||||||
@@ -165,7 +165,7 @@ export type CrossoverFilters = {
|
|||||||
|
|
||||||
export type MatrixRoutes = {
|
export type MatrixRoutes = {
|
||||||
connected: InputChannel[];
|
connected: InputChannel[];
|
||||||
gains: number[];
|
gains: Record<InputChannel, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PEQChain = {
|
export type PEQChain = {
|
||||||
@@ -304,9 +304,7 @@ const createDefaultPEQ = (): PEQ => ({
|
|||||||
|
|
||||||
const createDefaultPEQChain = (): PEQChain => ({
|
const createDefaultPEQChain = (): PEQChain => ({
|
||||||
bands: Object.fromEntries(
|
bands: Object.fromEntries(
|
||||||
Object.values(PEQBand)
|
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
|
||||||
.filter((band): band is PEQBand => typeof band === 'number')
|
|
||||||
.map((band) => [band, createDefaultPEQ()])
|
|
||||||
) as Record<PEQBand, PEQ>,
|
) as Record<PEQBand, PEQ>,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
});
|
});
|
||||||
@@ -341,8 +339,13 @@ const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
|
|||||||
name,
|
name,
|
||||||
|
|
||||||
matrix_routes: {
|
matrix_routes: {
|
||||||
connected: [],
|
connected: [InputChannel.InA],
|
||||||
gains: [],
|
gains: {
|
||||||
|
[InputChannel.InA]: 0,
|
||||||
|
[InputChannel.InB]: 0,
|
||||||
|
[InputChannel.InC]: 0,
|
||||||
|
[InputChannel.InD]: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
crossover: createDefaultCrossoverFilters(),
|
crossover: createDefaultCrossoverFilters(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user