feat: add matrix section
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
|
||||
@@ -7,7 +7,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
@@ -15,7 +14,6 @@ export default function Button({
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
fontSize = 13,
|
||||
type = 'button',
|
||||
style,
|
||||
...props
|
||||
@@ -26,7 +24,6 @@ export default function Button({
|
||||
className={`btn btn-${variant} ${className}`}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -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 { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
||||
|
||||
export type SidebarItem = {
|
||||
id: string;
|
||||
export type SidebarItem<T extends string = string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
@@ -13,12 +13,12 @@ export type SidebarAppOption = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function SidebarButton({
|
||||
function SidebarButton<T extends string>({
|
||||
item,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
item: SidebarItem;
|
||||
item: SidebarItem<T>;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
@@ -218,7 +218,7 @@ function AppCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
export function Sidebar<T extends string>({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
@@ -230,9 +230,9 @@ export default function Sidebar({
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
items: SidebarItem[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
items: SidebarItem<T>[];
|
||||
activeId: T | null;
|
||||
onSelect: (id: T) => void;
|
||||
appName: string;
|
||||
appOptions?: SidebarAppOption[];
|
||||
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;
|
||||
Reference in New Issue
Block a user