feat: add matrix section

This commit is contained in:
2026-08-12 18:45:57 +02:00
parent eb751f9404
commit 00adc4df53
16 changed files with 657 additions and 1986 deletions
+2
View File
@@ -17,6 +17,8 @@
font-weight: var(--font-weight-medium);
cursor: pointer;
font-size: 13px;
}
.btn:disabled {
-3
View File
@@ -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;
+8 -8
View File
@@ -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;
@@ -126,87 +126,6 @@
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 {
font-size: var(--font-xs);
color: var(--text-muted);
@@ -2,12 +2,11 @@
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../types';
import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import MeterBar from '../../../../components/MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../../assets/icons/Triangle';
import { useMemo, useState } from 'react';
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
import './CompressorSection.css';
// Enum order defines stepper order (1:1 -> Limit).
@@ -109,85 +108,6 @@ type SetCompressorFn = (
threshold: number
) => 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({
channel,
dsp,
@@ -1,6 +1,5 @@
import { useState } from 'react';
import { DSP } from '../../../../types';
import Sidebar from '../../../../components/Sidebar/Sidebar';
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
import GainSection from '../GainSection/GainSection';
@@ -14,19 +13,19 @@ import MatrixSection from '../Matrixsection/Matrixsection';
import GeqSection from '../GeqSection/GeqSection';
import InputSection from '../InputSection/InputSection';
import { DSP408Api } from '../../dsp408';
import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants';
import './DSP408Panel.css';
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
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 [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states;
const sidebarItems = [
const sidebarItems: SidebarItem<SidebarId>[] = [
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
@@ -155,6 +154,7 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
dsp={dsp}
setMatrixRouting={api.setMatrixRouting}
setMatrixGain={api.setMatrixGain}
setActiveSidebar={setActiveSidebar}
/>
);
@@ -1,3 +1,4 @@
/* MatrixSection.css */
.matrix-section {
container-type: size;
position: absolute;
@@ -8,7 +9,7 @@
height: var(--dsp-control-heigth);
display: flex;
align-items: stretch;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
@@ -20,152 +21,73 @@
border-top: var(--border-width) solid var(--border-hairline);
}
/* ---------- Placeholder graph area (top) ---------- */
/* Intentionally minimal for now — see chat for ideas on what belongs here. */
.matrix-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
.matrix-panels {
height: 100%;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
gap: var(--space-2);
overflow-x: auto;
}
.matrix-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
.matrix-panel {
position: relative;
display: flex;
align-items: center;
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;
aspect-ratio: 0.6;
flex: 0 0 auto;
display: flex;
flex-direction: column;
padding: var(--space-2);
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);
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
overflow: hidden;
box-sizing: border-box;
background: var(--bg-void);
}
.matrix-grid__corner,
.matrix-grid__col-header,
.matrix-grid__row-header {
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;
.matrix-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-size: 2.5cqh;
padding: 0.5cqh;
font-family: var(--font-ui);
font-size: 3cqh;
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-height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.matrix-grid__row-header {
justify-content: flex-start;
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;
.matrix-panel__rows {
flex: 1 1 auto;
min-height: 0;
border: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: none;
display: grid;
grid-template-rows: repeat(4, 1fr);
gap: 5cqh;
padding-block: 5cqh;
box-sizing: border-box;
}
.matrix-cell:focus-visible {
outline: 2px solid var(--accent-brand);
outline-offset: -2px;
.matrix-panel__row {
min-height: 0;
min-width: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3cqh;
padding-inline: 3cqh;
}
.matrix-cell--off {
color: var(--text-muted);
}
.matrix-cell--off .matrix-cell__dot {
width: 0.8cqh;
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;
.matrix-flow {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
box-sizing: border-box;
padding: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
@@ -1,183 +1,49 @@
// MatrixSection.tsx
import { Fragment, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../types';
import { InputChannel, OutputChannel, MatrixRoutes } 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';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in
// dsp408State, e.g. In1..In4. Adjust the import/labels below if the real
// enum differs.
const INPUTS: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const OUTPUTS: OutputChannel[] = [
OutputChannel.Out1,
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));
const GAIN_VALUES: number[] = (() => {
const values: number[] = [];
for (let v = MATRIX_GAIN_MIN; v <= MATRIX_GAIN_MAX + 1e-9; v += MATRIX_GAIN_STEP) {
values.push(Math.round(v * 10) / 10);
}
return values;
})();
function formatGain(v: number) {
return `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
function getMatrixRoutes(dsp: DSP, output: OutputChannel): MatrixRoutes {
return dsp.state.current_config.output_states[output].matrix_routes;
}
// 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 {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs?.includes(input) ?? false;
return getMatrixRoutes(dsp, output).connected.includes(input);
}
function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_gains?.[input] ?? GAIN_DEFAULT;
return getMatrixRoutes(dsp, output).gains[input];
}
function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
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>
);
return getMatrixRoutes(dsp, output).connected;
}
function MatrixSection({
dsp,
setMatrixRouting,
setMatrixGain,
setActiveSidebar,
}: {
dsp: DSP;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
@@ -186,21 +52,16 @@ function MatrixSection({
outputChannel: OutputChannel,
gain: number
) => 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 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) => {
// Give a freshly-connected link a defined starting gain rather than
// leaving it at whatever stale value the backend last had for it.
if (ok && nowConnecting) {
void setMatrixGain(input, output, GAIN_DEFAULT);
}
});
const isConnected = current.includes(input);
const next = isConnected ? current.filter((channel) => channel !== input) : [...current, input];
void setMatrixRouting(output, next);
};
const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
@@ -209,46 +70,59 @@ function MatrixSection({
return (
<>
<div className="matrix-graph-section">
<div className="matrix-graph-panel">Routing overview — see chat for ideas</div>
<div className="matrix-flow">
<SignalFlowMatrix
dsp={dsp}
setMatrixRouting={setMatrixRouting}
setMatrixGain={setMatrixGain}
setActiveSidebar={setActiveSidebar}
/>
</div>
<div className="matrix-section">
<div
className="matrix-grid-panel"
style={{ ['--matrix-input-count' as string]: INPUTS.length }}
>
<div className="matrix-grid">
<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">
<div className="matrix-panels">
<ChannelGroup title="Output Routing">
{OUTPUT_ORDER.map((output) => (
<div className="matrix-panel" key={String(output)}>
<div className="matrix-panel__title">
{dsp.state.current_config.output_states[output].name}
</div>
{INPUTS.map((input) => {
<div className="matrix-panel__rows">
{INPUT_ORDER.map((input) => {
const connected = isRouted(dsp, output, input);
const gain = getLinkGain(dsp, output, input);
return (
<MatrixCell
key={`${String(output)}-${String(input)}`}
output={output}
input={input}
connected={connected}
gain={getLinkGain(dsp, output, input)}
onToggle={() => handleToggle(output, input)}
onGainChange={(gain) => handleGainChange(output, input, gain)}
<div className="matrix-panel__row" key={String(input)}>
<Button
variant="toggle"
className={connected ? 'active-info' : ''}
style={{
fontSize: '4cqh',
padding: 0,
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>
))}
</ChannelGroup>
</div>
</div>
</>
);
+41 -1
View File
@@ -1,4 +1,4 @@
import { DiscreteFrequency, InputChannel } from './types';
import { DiscreteFrequency, InputChannel, OutputChannel } from './types';
export const INPUT_ORDER: InputChannel[] = [
InputChannel.InA,
@@ -7,6 +7,17 @@ export const INPUT_ORDER: InputChannel[] = [
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> = {
[DiscreteFrequency.F20]: 20,
[DiscreteFrequency.F25]: 25,
@@ -82,8 +93,37 @@ export const GEQ_GAIN_MIN = -12;
export const GEQ_GAIN_MAX = 12;
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 ------
export const GRAPH_X_MIN = 10;
export const GRAPH_X_MAX = 30000;
export const GRAPH_Y_MIN = -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';
+4 -5
View File
@@ -952,7 +952,6 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
const cfg = d.state.current_config;
const outputState = cfg.output_states[outputChannel];
const inputIndex = Object.values(InputChannel).indexOf(inputChannel);
return {
...d,
@@ -966,9 +965,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...outputState,
matrix_routes: {
...outputState.matrix_routes,
gains: outputState.matrix_routes.gains.map((g, i) =>
i === inputIndex ? gain : g
),
gains: {
...outputState.matrix_routes.gains,
[inputChannel]: gain,
},
},
},
},
@@ -977,7 +977,6 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
};
});
};
if (dsp.demo) {
updateMatrixGain();
return true;
+8 -3
View File
@@ -165,7 +165,7 @@ export type CrossoverFilters = {
export type MatrixRoutes = {
connected: InputChannel[];
gains: number[];
gains: Record<InputChannel, number>;
};
export type PEQChain = {
@@ -341,8 +341,13 @@ const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [],
gains: [],
connected: [InputChannel.InA],
gains: {
[InputChannel.InA]: 0,
[InputChannel.InB]: 0,
[InputChannel.InC]: 0,
[InputChannel.InD]: 0,
},
},
crossover: createDefaultCrossoverFilters(),
File diff suppressed because it is too large Load Diff