refactor: folders/files to group dsp logic

This commit is contained in:
2026-08-11 23:17:47 +02:00
parent d91bcaa20f
commit 34fad6f905
35 changed files with 194 additions and 337 deletions
@@ -1,4 +1,4 @@
import type { Notification } from '../../types/types';
import type { Notification } from '../../types';
import './Notifications.css';
type Props = {
@@ -1,289 +0,0 @@
.compressor-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.compressor-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.compressor-panel {
position: relative;
aspect-ratio: 0.5;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.compressor-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.compressor-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'threshold attack'
'steppers release';
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.compressor-panel__cell--threshold {
grid-area: threshold;
}
.compressor-panel__cell--attack {
grid-area: attack;
}
.compressor-panel__cell--release {
grid-area: release;
}
.compressor-panel__cell--steppers {
grid-area: steppers;
}
.compressor-panel__cell--steppers {
flex-direction: column;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__stepper-item {
flex: 1 1 0;
min-height: 0;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.compressor-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
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);
}
.compressor-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.compressor-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-meter-panel {
flex: 0 0 auto;
width: fit-content;
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-graph__header {
flex: 0 0 auto;
display: flex;
align-items: baseline;
justify-content: space-between;
font-family: var(--font-ui);
padding: 1cqh;
}
.compressor-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.compressor-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1cqh;
padding: 1cqh;
}
.compressor-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -1,436 +0,0 @@
// CompressorSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../assets/icons/Triangle';
import './CompressorSection.css';
// Enum order defines stepper order (1:1 -> Limit).
const RATIO_ORDER: Ratio[] = [
Ratio.Ratio1_1,
Ratio.Ratio1_1_1,
Ratio.Ratio1_1_3,
Ratio.Ratio1_1_5,
Ratio.Ratio1_1_7,
Ratio.Ratio1_2,
Ratio.Ratio1_2_5,
Ratio.Ratio1_3,
Ratio.Ratio1_3_5,
Ratio.Ratio1_4,
Ratio.Ratio1_5,
Ratio.Ratio1_6,
Ratio.Ratio1_8,
Ratio.Ratio1_10,
Ratio.Ratio1_20,
Ratio.Limit,
];
const RATIO_LABEL: Record<Ratio, string> = {
[Ratio.Ratio1_1]: '1:1',
[Ratio.Ratio1_1_1]: '1.1:1',
[Ratio.Ratio1_1_3]: '1.3:1',
[Ratio.Ratio1_1_5]: '1.5:1',
[Ratio.Ratio1_1_7]: '1.7:1',
[Ratio.Ratio1_2]: '2:1',
[Ratio.Ratio1_2_5]: '2.5:1',
[Ratio.Ratio1_3]: '3:1',
[Ratio.Ratio1_3_5]: '3.5:1',
[Ratio.Ratio1_4]: '4:1',
[Ratio.Ratio1_5]: '5:1',
[Ratio.Ratio1_6]: '6:1',
[Ratio.Ratio1_8]: '8:1',
[Ratio.Ratio1_10]: '10:1',
[Ratio.Ratio1_20]: '20:1',
[Ratio.Limit]: 'Limit',
};
// Numeric ratio used only for drawing the transfer curve; Limit ~= brickwall.
const RATIO_NUMERIC: Record<Ratio, number> = {
[Ratio.Ratio1_1]: 1,
[Ratio.Ratio1_1_1]: 1.1,
[Ratio.Ratio1_1_3]: 1.3,
[Ratio.Ratio1_1_5]: 1.5,
[Ratio.Ratio1_1_7]: 1.7,
[Ratio.Ratio1_2]: 2,
[Ratio.Ratio1_2_5]: 2.5,
[Ratio.Ratio1_3]: 3,
[Ratio.Ratio1_3_5]: 3.5,
[Ratio.Ratio1_4]: 4,
[Ratio.Ratio1_5]: 5,
[Ratio.Ratio1_6]: 6,
[Ratio.Ratio1_8]: 8,
[Ratio.Ratio1_10]: 10,
[Ratio.Ratio1_20]: 20,
[Ratio.Limit]: 1000,
};
const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
const COMPRESSOR_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Adjust to match dsp.meters.channels ordering.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
type CompressorValues = {
ratio: Ratio;
attack: number;
release: number;
knee: number;
threshold: number;
};
type SetCompressorFn = (
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
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,
setCompressor,
onLiveValue,
}: {
channel: OutputChannel;
dsp: DSP;
setCompressor: SetCompressorFn;
onLiveValue: (channel: OutputChannel, key: CompressorParamKey, value: number | Ratio) => void;
}) {
const outputState = dsp.state.current_config.output_states[channel];
const comp = outputState.compressor as CompressorValues;
const commit = (overrides: Partial<CompressorValues>) => {
const next = { ...comp, ...overrides };
return setCompressor(channel, next.ratio, next.attack, next.release, next.knee, next.threshold);
};
return (
<div className="compressor-panel">
<div className="compressor-panel__title">{outputState.name}</div>
<div className="compressor-panel__grid">
{COMPRESSOR_SLIDER_PARAMS.map((param) => (
<div
className={`compressor-panel__cell compressor-panel__cell--${param.key}`}
key={param.key}
>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={comp[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<CompressorValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="compressor-panel__cell-label">{param.label}</div>
</div>
))}
<div className="compressor-panel__cell compressor-panel__cell--steppers">
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.ratio}
values={RATIO_ORDER}
formatLabel={(v) => RATIO_LABEL[v]}
onChange={(value) => {
onLiveValue(channel, 'ratio', value);
commit({ ratio: value });
}}
/>
<div className="compressor-panel__cell-label">Ratio</div>
</div>
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.knee}
values={KNEE_VALUES}
formatLabel={(v) => `${v} dB`}
onChange={(value) => {
onLiveValue(channel, 'knee', value);
commit({ knee: value });
}}
/>
<div className="compressor-panel__cell-label">Knee</div>
</div>
</div>
</div>
</div>
);
}
// Soft-knee compressor transfer curve, in dB.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2;
if (knee <= 0 || input <= kneeStart) {
return input <= threshold ? input : threshold + (input - threshold) / ratio;
}
if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio;
}
const x = input - kneeStart;
return input + ((1 / ratio - 1) * x * x) / (2 * knee);
}
function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) {
const outputs = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const [activeChannel, setActiveChannel] = useState<OutputChannel>(outputs[0]);
const [liveComp, setLiveComp] = useState<Partial<Record<OutputChannel, CompressorValues>>>({});
const handleLiveValue = (
channel: OutputChannel,
key: CompressorParamKey,
value: number | Ratio
) => {
setActiveChannel(channel);
setLiveComp((prev) => {
const base =
prev[channel] ??
(dsp.state.current_config.output_states[channel].compressor as CompressorValues);
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetCompressor: SetCompressorFn = async (
channel,
ratio,
attack,
release,
knee,
threshold
) => {
setActiveChannel(channel);
const success = await set_compressor(channel, ratio, attack, release, knee, threshold);
if (success) {
setLiveComp((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeOutputState = dsp.state.current_config.output_states[activeChannel];
const activeComp = liveComp[activeChannel] ?? (activeOutputState.compressor as CompressorValues);
const curvePoints = useMemo(() => {
const { threshold, ratio, knee } = activeComp;
const ratioNumeric = RATIO_NUMERIC[ratio];
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
}
return points;
}, [activeComp.threshold, activeComp.ratio, activeComp.knee]);
return (
<>
<div className="compressor-linear-graph-section">
<div className="compressor-graph-panel">
<div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div>
<LinearGraph
xMin={-60}
xMax={0}
yMin={-60}
yMax={0}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
// Unity reference line
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60);
const [ux1, uy1] = toPx(0, 0);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
ctx.stroke();
ctx.setLineDash([]);
// Threshold marker
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -60);
const [tx1, ty1] = toPx(activeComp.threshold, 0);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
ctx.stroke();
ctx.setLineDash([]);
// Compression curve
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
curvePoints.forEach(([x, y], i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="compressor-meter-panel">
<div className="compressor-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
);
})}
</div>
</div>
</div>
<div className="compressor-section">
<div className="compressor-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<CompressorPanel
key={String(output)}
channel={output}
dsp={dsp}
setCompressor={handleSetCompressor}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default CompressorSection;
@@ -1,107 +0,0 @@
.connection-section {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.connection-panel {
width: 340px;
display: flex;
flex-direction: column;
}
.connection-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
}
.connection-panel__header-text {
flex: 1;
min-width: 0;
}
.connection-panel__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-panel__type {
margin-top: var(--space-1);
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.connection-panel__specs {
margin: var(--space-5) 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.connection-panel__spec {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.connection-panel__spec dt {
color: var(--text-muted);
font-size: var(--font-sm);
}
.connection-panel__spec dd {
margin: 0;
color: var(--text-primary);
}
.connection-panel__mono {
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-panel__actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
}
.connection-panel__status {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.connection-panel__led {
height: 14px;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
@@ -1,82 +0,0 @@
import { DSP } from '../../../types/types';
import Button from '../../Button/Button';
import Card from '../../Card/Card';
import Led from '../../Led/Led';
import './ConnectionSection.css';
function ConnectionPanel({
dsp,
onConnect,
onDisconnect,
}: {
dsp: DSP;
onConnect: () => void;
onDisconnect: () => void;
}) {
const statusLabel =
dsp.status === 'connected'
? 'Connected'
: dsp.status === 'connecting'
? 'Connecting…'
: 'Disconnected';
return (
<div className="connection-section">
<Card className="connection-panel">
<div className="connection-panel__header">
<div className="connection-panel__header-text">
<h1 className="connection-panel__title" title={dsp.name}>
{dsp.name}
</h1>
<div className="connection-panel__type">{dsp.type}</div>
</div>
<div className="connection-panel__status">
<span className="connection-panel__led">
<Led status={dsp.status} />
</span>
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
</div>
</div>
<dl className="connection-panel__specs">
<div className="connection-panel__spec">
<dt>IP address</dt>
<dd className="connection-panel__mono" title={dsp.ip}>
{dsp.ip}
</dd>
</div>
<div className="connection-panel__spec">
<dt>Port</dt>
<dd className="connection-panel__mono">{dsp.port}</dd>
</div>
<div className="connection-panel__spec">
<dt>Device ID</dt>
<dd className="connection-panel__mono" title={String(dsp.deviceId) || undefined}>
{dsp.deviceId || '—'}
</dd>
</div>
</dl>
<div className="connection-panel__actions">
{dsp.status === 'connected' ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
</Button>
)}
</div>
</Card>
</div>
);
}
export default ConnectionPanel;
@@ -1,117 +0,0 @@
.delay-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.delay-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.delay-panel {
position: relative;
height: 100%;
aspect-ratio: 0.26;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.delay-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.delay-unit-selector {
height: 100%;
width: 20cqh;
display: flex;
flex-direction: column;
gap: 5cqh;
justify-content: center;
box-sizing: border-box;
}
.delay-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.delay-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.delay-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.delay-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -1,368 +0,0 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel, DelayUnit } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './DelaySection.css';
const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
const UNIT_LABEL: Record<DelayUnit, string> = {
[DelayUnit.Millisecond]: 'ms',
[DelayUnit.Meter]: 'm',
[DelayUnit.Feet]: 'ft',
};
const UNIT_ORDER: DelayUnit[] = [DelayUnit.Millisecond, DelayUnit.Meter, DelayUnit.Feet];
const DELAY_STEPS = 255;
const DELAY_STEP: Record<DelayUnit, number> = {
[DelayUnit.Millisecond]: (680.0 - 0) / (DELAY_STEPS - 1),
[DelayUnit.Meter]: (233.58 - 0) / (DELAY_STEPS - 1),
[DelayUnit.Feet]: (766.329 - 0) / (DELAY_STEPS - 1),
};
type SetDelayFn = (channel: Channel, delay: number) => Promise<boolean>;
function channelKey(channel: Channel): string {
return 'Input' in channel ? `Input:${channel.Input}` : `Output:${channel.Output}`;
}
function DelayPanel({
channel,
dsp,
setDelay,
onLiveValue,
}: {
channel: Channel;
dsp: DSP;
setDelay: SetDelayFn;
onLiveValue: (channel: Channel, value: number) => void;
}) {
function getChannelState(channel: Channel) {
if ('Input' in channel) {
return dsp.state.current_config.input_states[channel.Input];
} else {
return dsp.state.current_config.output_states[channel.Output];
}
}
const state = getChannelState(channel);
const unit = dsp.state.current_config.delay_unit;
const [min, max] = DELAY_RANGES[unit];
return (
<div className="delay-panel">
<div className="delay-panel__title">{state.name}</div>
<VerticalSlider
min={min}
max={max}
step={() => DELAY_STEP[unit]}
unit={' ' + UNIT_LABEL[unit]}
value={state.delay}
onInput={(value) => onLiveValue(channel, value)}
onChange={(value) => setDelay(channel, value)}
/>
</div>
);
}
function DelayGraph({
title,
rows,
unit,
}: {
title: string;
rows: { label: string; delay: number }[];
unit: DelayUnit;
}) {
const [min, max] = DELAY_RANGES[unit];
const labelByX = useMemo(() => {
const map = new Map<number, string>();
rows.forEach((row, i) => map.set(i + 1, row.label));
return map;
}, [rows]);
return (
<div className="delay-graph-panel">
<div className="delay-graph-title">{title}</div>
<LinearGraph
xMin={0.5}
xMax={rows.length + 0.5}
xStep={1}
yMin={min}
yMax={max}
xLabel="Channel"
yLabel={UNIT_LABEL[unit]}
showGrid={true}
formatX={(v) => labelByX.get(Math.round(v)) ?? ''}
formatY={(v) => v.toFixed(0)}
draw={({ ctx, toPx, plot }) => {
const barWidth = (plot.width / rows.length) * 0.55;
rows.forEach((row, i) => {
const x = i + 1;
const [xPx, baseY] = toPx(x, min);
const [, topY] = toPx(x, row.delay);
ctx.fillStyle = '#4ade80';
ctx.fillRect(xPx - barWidth / 2, topY, barWidth, baseY - topY);
});
}}
/>
</div>
);
}
function DelayUnitSelector({
unit,
onSelect,
}: {
unit: DelayUnit;
onSelect: (unit: DelayUnit) => void;
}) {
return (
<div className="delay-unit-selector">
{UNIT_ORDER.map((u) => (
<Button
key={u}
variant="toggle"
className={u === unit ? 'active-info' : ''}
onClick={() => onSelect(u)}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{UNIT_LABEL[u]}
</Button>
))}
</div>
);
}
function DelaySection({
dsp,
setDelay,
setDelayUnit,
setDSPState,
}: {
dsp: DSP;
setDelay: SetDelayFn;
setDelayUnit: (unit: DelayUnit) => Promise<boolean>;
setDSPState: (updater: (dsp: DSP) => DSP) => void;
}) {
const unit = dsp.state?.current_config.delay_unit ?? DelayUnit.Millisecond;
const inputs: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
const [liveDelay, setLiveDelay] = useState<Partial<Record<string, number>>>({});
const handleLiveValue = (channel: Channel, value: number) => {
setLiveDelay((prev) => ({
...prev,
[channelKey(channel)]: value,
}));
};
const handleSetDelay: SetDelayFn = async (channel, delay) => {
const success = await setDelay(channel, delay);
if (success) {
const key = channelKey(channel);
setLiveDelay((prev) => {
if (!(key in prev)) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
return success;
};
const committedDelay = (channel: Channel): number =>
'Input' in channel
? dsp.state.current_config.input_states[channel.Input].delay
: dsp.state.current_config.output_states[channel.Output].delay;
const getDelay = (channel: Channel): number => {
const key = channelKey(channel);
if (key in liveDelay) {
return liveDelay[key]!;
}
return committedDelay(channel);
};
function convertDelayUnit(value: number, from: DelayUnit, to: DelayUnit): number {
const [, fromMax] = DELAY_RANGES[from];
const [, toMax] = DELAY_RANGES[to];
if (fromMax === 0) return 0;
const ratio = value / fromMax;
return ratio * toMax;
}
const handleUnitChange = async (newUnit: DelayUnit) => {
if (newUnit === unit) return;
const success = await setDelayUnit(newUnit);
if (!success) return;
setDSPState((d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
delay_unit: newUnit,
input_states: {
...currentConfig.input_states,
[InputChannel.InA]: {
...currentConfig.input_states[InputChannel.InA],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InA].delay,
unit,
newUnit
),
},
[InputChannel.InB]: {
...currentConfig.input_states[InputChannel.InB],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InB].delay,
unit,
newUnit
),
},
[InputChannel.InC]: {
...currentConfig.input_states[InputChannel.InC],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InC].delay,
unit,
newUnit
),
},
[InputChannel.InD]: {
...currentConfig.input_states[InputChannel.InD],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InD].delay,
unit,
newUnit
),
},
},
output_states: {
...currentConfig.output_states,
...Object.fromEntries(
Object.values(OutputChannel).map((channel) => [
channel,
{
...currentConfig.output_states[channel],
delay: convertDelayUnit(
currentConfig.output_states[channel].delay,
unit,
newUnit
),
},
])
),
},
},
},
};
});
};
const inputRows = inputs.map((channel) => ({
label: dsp.state.current_config.input_states[channel].name,
delay: getDelay({ Input: channel }),
}));
const outputRows = outputs.map(({ channel, title }) => ({
label: title,
delay: getDelay(channel),
}));
return (
<>
<div className="delay-linear-graph-section">
<DelayGraph title="Input Delay" rows={inputRows} unit={unit} />
<DelayGraph title="Output Delay" rows={outputRows} unit={unit} />
</div>
<div className="delay-section">
<div className="delay-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
<DelayPanel
key={channel}
channel={{ Input: channel }}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<DelayPanel
key={title}
channel={channel}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Unit">
<DelayUnitSelector unit={unit} onSelect={handleUnitChange} />
</ChannelGroup>
</div>
</div>
</>
);
}
export default DelaySection;
@@ -1,105 +0,0 @@
.gain-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.gain-panel {
position: relative;
height: 100%;
aspect-ratio: 1 / 4;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.gain-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gain-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.gain-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gain-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.gain-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -1,205 +0,0 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import './GainSection.css';
function GainPanel({
dsp,
channel,
title,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: Channel;
title: string;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const muted = channelState.mute;
const inverted = channelState.phase_inverted;
const gain = channelState.gain;
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
return (
<div className="gain-panel">
<div className="gain-panel__title">{title}</div>
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
/>
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
);
}
function GainSection({
dsp,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const inputs: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
return (
<>
<div className="gain-linear-graph-section">
<div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div>
<LinearGraph
xMin={10}
xMax={30000}
xScale="log"
yMin={-18}
yMax={18}
yStep={3}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
<div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="deg"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div>
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
<GainPanel
key={channel}
dsp={dsp}
channel={{ Input: channel }}
title={dsp.state.current_config.input_states[channel].name}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel
key={title}
dsp={dsp}
channel={channel}
title={title}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default GainSection;
@@ -1,159 +0,0 @@
/* GateSection.css */
.gate-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 40%;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gate-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.gate-panel {
position: relative;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.gate-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gate-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.gate-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.gate-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.gate-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.gate-graph-panel {
flex: 1 1 auto; /* take remaining space */
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-meter-panel {
flex: 0 0 auto; /* only required width */
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.gate-graph-panel > .container {
flex: 1;
min-height: 0;
}
.gate-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: 1cqh;
padding: 1cqh;
}
@@ -1,214 +0,0 @@
// GateSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './GateSection.css';
const GATE_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 0, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'hold', label: 'Hold', min: 10, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
const INPUT_METER_INDEX: Record<InputChannel, number> = {
[InputChannel.InA]: 0,
[InputChannel.InB]: 1,
[InputChannel.InC]: 2,
[InputChannel.InD]: 3,
} as const;
type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
type GateValues = {
threshold: number;
attack: number;
hold: number;
release: number;
};
type SetGateFn = (
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
) => Promise<boolean>;
function GatePanel({
channel,
dsp,
setGate,
onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
setGate: SetGateFn;
onLiveValue: (channel: InputChannel, key: GateParamKey, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const gate = inputState.gate;
return (
<div className="gate-panel">
<div className="gate-panel__title">{inputState.name}</div>
<div className="gate-panel__grid">
{GATE_PARAMS.map((param) => (
<div className="gate-panel__cell" key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={gate[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => {
return setGate(
channel,
param.key === 'threshold' ? value : gate.threshold,
param.key === 'attack' ? value : gate.attack,
param.key === 'hold' ? value : gate.hold,
param.key === 'release' ? value : gate.release
);
}}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="gate-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const inputs = [InputChannel.InA, InputChannel.InB, InputChannel.InC, InputChannel.InD];
const [activeChannel, setActiveChannel] = useState<InputChannel>(inputs[0]);
const [liveGate, setLiveGate] = useState<Partial<Record<InputChannel, GateValues>>>({});
const handleLiveValue = (channel: InputChannel, key: GateParamKey, value: number) => {
setActiveChannel(channel);
setLiveGate((prev) => {
const base = prev[channel] ?? dsp.state.current_config.input_states[channel].gate;
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetGate: SetGateFn = async (channel, threshold, attack, hold, release) => {
setActiveChannel(channel);
const success = await set_gate(channel, threshold, attack, hold, release);
if (success) {
setLiveGate((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGate = liveGate[activeChannel] ?? activeInputState.gate;
const envelope = useMemo(() => {
const { threshold, attack, hold, release } = activeGate;
const roll = Math.max(20, (attack + hold + release) * 0.08);
const t0 = 0;
const t1 = t0 + roll;
const t2 = t1 + attack;
const t3 = t2 + hold;
const t4 = t3 + release;
const t5 = t4 + roll;
return { threshold, t0, t1, t2, t3, t4, t5 };
}, [activeGate.threshold, activeGate.attack, activeGate.hold, activeGate.release]);
return (
<>
<div className="gate-linear-graph-section">
<div className="gate-graph-panel">
<div className="gate-graph-title">Frequency Response</div>
<LinearGraph
xMin={envelope.t0}
xMax={envelope.t5}
yMin={-90}
yMax={0}
xLabel="ms"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ toPx, ctx }) => {
const { threshold, t0, t1, t2, t3, t4, t5 } = envelope;
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [tx0, ty] = toPx(t0, threshold);
const [tx1] = toPx(t5, threshold);
ctx.beginPath();
ctx.moveTo(tx0, ty);
ctx.lineTo(tx1, ty);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
const points: [number, number][] = [
[t0, -90],
[t1, -90],
[t2, 0],
[t3, 0],
[t4, -90],
[t5, -90],
];
ctx.beginPath();
points.forEach(([t, g], i) => {
const [px, py] = toPx(t, g);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="gate-meter-panel">
<div className="gate-graph__meters">
{inputs.map((input) => {
const inputState = dsp.state.current_config.input_states[input];
const idx = INPUT_METER_INDEX[input];
const meter = dsp.meters.channels[idx];
return <MeterBar label={inputState.name} meter={meter} />;
})}
</div>
</div>
</div>
<div className="gate-section">
<div className="gate-panels">
<ChannelGroup title="Input">
{inputs.map((input) => (
<GatePanel
key={String(input)}
channel={input}
dsp={dsp}
setGate={handleSetGate}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default GateSection;
@@ -1,143 +0,0 @@
.geq-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.geq-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.geq-band-panel {
position: relative;
height: 100%;
aspect-ratio: 4.2;
flex: 0 0 auto;
min-width: 0;
display: flex;
flex-direction: row;
align-items: stretch;
/* padding: 2cqh 1.5cqh; */
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 0cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
padding-top: 2cqh;
}
.geq-band-cell {
flex: 1 1 0;
min-width: 2cqh;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.6cqh;
}
.geq-band-cell__label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 3.2cqh;
color: var(--text-muted);
white-space: nowrap;
}
.geq-channel-selector {
height: 100%;
width: 20cqh;
display: flex;
flex-direction: column;
gap: 5cqh;
justify-content: center;
box-sizing: border-box;
}
.geq-side-panels {
height: 100%;
display: flex;
flex-direction: column;
gap: 2cqh;
}
.geq-side-panels > :first-child {
flex: 0 0 60%;
}
.geq-side-panels > :last-child {
flex: 1 1 auto;
min-height: 0;
}
.geq-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.geq-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.geq-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.geq-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -1,347 +0,0 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, DiscreteFrequency } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './GeqSection.css';
const GEQ_BANDS = [
{ frequency: DiscreteFrequency.F20, label: '20' },
{ frequency: DiscreteFrequency.F25, label: '25' },
{ frequency: DiscreteFrequency.F31_5, label: '31.5' },
{ frequency: DiscreteFrequency.F40, label: '40' },
{ frequency: DiscreteFrequency.F50, label: '50' },
{ frequency: DiscreteFrequency.F63, label: '63' },
{ frequency: DiscreteFrequency.F80, label: '80' },
{ frequency: DiscreteFrequency.F100, label: '100' },
{ frequency: DiscreteFrequency.F125, label: '125' },
{ frequency: DiscreteFrequency.F160, label: '160' },
{ frequency: DiscreteFrequency.F200, label: '200' },
{ frequency: DiscreteFrequency.F250, label: '250' },
{ frequency: DiscreteFrequency.F315, label: '315' },
{ frequency: DiscreteFrequency.F400, label: '400' },
{ frequency: DiscreteFrequency.F500, label: '500' },
{ frequency: DiscreteFrequency.F630, label: '630' },
{ frequency: DiscreteFrequency.F800, label: '800' },
{ frequency: DiscreteFrequency.F1000, label: '1k' },
{ frequency: DiscreteFrequency.F1250, label: '1.25k' },
{ frequency: DiscreteFrequency.F1600, label: '1.6k' },
{ frequency: DiscreteFrequency.F2000, label: '2k' },
{ frequency: DiscreteFrequency.F2500, label: '2.5k' },
{ frequency: DiscreteFrequency.F3150, label: '3.15k' },
{ frequency: DiscreteFrequency.F4000, label: '4k' },
{ frequency: DiscreteFrequency.F5000, label: '5k' },
{ frequency: DiscreteFrequency.F6300, label: '6.3k' },
{ frequency: DiscreteFrequency.F8000, label: '8k' },
{ frequency: DiscreteFrequency.F10000, label: '10k' },
{ frequency: DiscreteFrequency.F12500, label: '12.5k' },
{ frequency: DiscreteFrequency.F16000, label: '16k' },
{ frequency: DiscreteFrequency.F20000, label: '20k' },
] as const;
const GEQ_GAIN_MIN = -12;
const GEQ_GAIN_MAX = 12;
const GEQ_GAIN_STEP = 0.1;
const INPUT_ORDER: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
type LiveBands = Partial<Record<DiscreteFrequency, number>>;
type SetGeqBandFn = (
input: InputChannel,
band: DiscreteFrequency,
gain: number
) => Promise<boolean>;
type SetGeqBypassFn = (input: InputChannel, bypass: boolean) => Promise<boolean>;
function GeqBandPanel({
channel,
dsp,
setBand,
onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
setBand: SetGeqBandFn;
onLiveValue: (channel: InputChannel, band: DiscreteFrequency, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const geq = inputState.geq;
return (
<div className="geq-band-panel">
{GEQ_BANDS.map(({ frequency, label }) => (
<div className="geq-band-cell" key={frequency}>
<VerticalSlider
min={GEQ_GAIN_MIN}
max={GEQ_GAIN_MAX}
step={() => GEQ_GAIN_STEP}
unit=""
showTicks={false}
value={geq.gains[frequency]}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onInput={(value) => onLiveValue(channel, frequency, value)}
onChange={(value) => setBand(channel, frequency, value)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="geq-band-cell__label">{label}</div>
</div>
))}
</div>
);
}
function GeqGraph({
title,
channel,
dsp,
liveBands,
}: {
title: string;
channel: InputChannel;
dsp: DSP;
liveBands: LiveBands | undefined;
}) {
const geq = dsp.state.current_config.input_states[channel].geq;
const labelByX = useMemo(() => {
const map = new Map<number, string>();
GEQ_BANDS.forEach((band, i) => map.set(i + 1, band.label));
return map;
}, []);
const values = GEQ_BANDS.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
);
return (
<div className="geq-graph-panel">
<div className="geq-graph-title">{title}</div>
<LinearGraph
xMin={0.5}
xMax={GEQ_BANDS.length + 0.5}
xStep={1}
yMin={GEQ_GAIN_MIN}
yMax={GEQ_GAIN_MAX}
xLabel="Band Hz"
yLabel="dB"
showGrid={true}
formatX={(v) => labelByX.get(Math.round(v)) ?? ''}
formatY={(v) => v.toFixed(0)}
draw={({ ctx, toPx, plot }) => {
const barWidth = (plot.width / GEQ_BANDS.length) * 0.6;
values.forEach((gain, i) => {
const x = i + 1;
const [xPx, zeroY] = toPx(x, 0);
const [, gainY] = toPx(x, gain);
ctx.fillStyle = gain >= 0 ? '#4ade80' : '#f87171';
const top = Math.min(zeroY, gainY);
const height = Math.abs(gainY - zeroY);
ctx.fillRect(xPx - barWidth / 2, top, barWidth, height);
});
}}
/>
</div>
);
}
function GeqChannelSelector({
channel,
inputs,
onSelect,
}: {
channel: InputChannel;
inputs: { channel: InputChannel; label: string }[];
onSelect: (channel: InputChannel) => void;
}) {
return (
<div className="geq-channel-selector">
{inputs.map(({ channel: c, label }) => (
<Button
key={c}
variant="toggle"
className={c === channel ? 'active-info' : ''}
onClick={() => onSelect(c)}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{label}
</Button>
))}
</div>
);
}
function GeqActions({
bypass,
resetting,
onBypass,
onReset,
}: {
bypass: boolean;
resetting: boolean;
onBypass: () => void;
onReset: () => void;
}) {
return (
<div className="geq-channel-selector">
<Button
variant="toggle"
className={bypass ? 'active-info' : ''}
onClick={onBypass}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
<Button
variant="toggle"
onClick={onReset}
disabled={resetting}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */}
</Button>
</div>
);
}
function GeqSection({
dsp,
setGeqBand,
setGeqBypass,
}: {
dsp: DSP;
setGeqBand: SetGeqBandFn;
setGeqBypass: SetGeqBypassFn;
}) {
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
const [resetting, setResetting] = useState(false); // NEW
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({
...prev,
[channel]: { ...prev[channel], [band]: value },
}));
};
const handleSetBand: SetGeqBandFn = async (channel, band, gain) => {
const success = await setGeqBand(channel, band, gain);
if (success) {
setLiveGeq((prev) => {
const channelLive = prev[channel];
if (!channelLive || !(band in channelLive)) return prev;
const next = { ...channelLive };
delete next[band];
return { ...prev, [channel]: next };
});
}
return success;
};
// NEW: sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => {
if (resetting) return;
setResetting(true);
for (const { frequency } of GEQ_BANDS) {
// Skip bands already at 0 to save a round trip.
const current = dsp.state.current_config.input_states[channel].geq.gains[frequency] ?? 0;
if (current === 0) continue;
await handleSetBand(channel, frequency, 0);
}
setResetting(false);
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel];
const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const channelOptions = INPUT_ORDER.map((channel) => ({
channel,
label: dsp.state.current_config.input_states[channel].name,
}));
return (
<>
<div className="geq-linear-graph-section">
<GeqGraph
title={`${activeInputState.name} - GEQ`}
channel={activeChannel}
dsp={dsp}
liveBands={activeLiveBands}
/>
</div>
<div className="geq-section">
<div className="geq-panels">
<ChannelGroup title="Bands">
<GeqBandPanel
channel={activeChannel}
dsp={dsp}
setBand={handleSetBand}
onLiveValue={handleLiveValue}
/>
</ChannelGroup>
<div className="geq-side-panels">
<ChannelGroup title="Input">
<GeqChannelSelector
channel={activeChannel}
inputs={channelOptions}
onSelect={setActiveChannel}
/>
</ChannelGroup>
<ChannelGroup title="Actions">
<GeqActions
bypass={activeGeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={() => handleReset(activeChannel)}
/>
</ChannelGroup>
</div>
</div>
</div>
</>
);
}
export default GeqSection;
@@ -1,342 +0,0 @@
/* InputSection.css */
.input-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.input-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.input-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.input-graph-panel > .container {
flex: 1;
min-height: 0;
}
.input-control-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.input-control-panel {
height: 100%;
width: 100%;
max-width: 60cqw;
display: flex;
align-items: stretch;
gap: var(--space-4);
box-sizing: border-box;
padding: 2cqh;
}
.input-control-panel__sliders {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4cqh;
}
.input-control-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.input-control-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.input-control-panel__side {
flex: 0 0 auto;
width: 16cqh;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2cqh;
}
.input-control-panel__select {
display: flex;
flex-direction: column;
gap: 0.5cqh;
}
.input-control-panel__select-label {
font-family: var(--font-ui);
font-size: 2.2cqh;
color: var(--text-muted);
}
.input-control-panel__select select {
font-family: var(--font-ui);
font-size: 2.5cqh;
padding: 0.8cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.input-control-panel__select select:disabled {
opacity: 0.5;
}
.input-control-panel__bypass-btn {
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
padding: 1cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
cursor: pointer;
}
.input-control-panel__bypass-btn--active {
background: var(--accent, #f87171);
color: var(--bg-void);
border-color: var(--accent, #f87171);
}
/* Peq Sliders */
.input-panel {
position: relative;
height: 10;
aspect-ratio: 0.75;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.input-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.input-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1fr;
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.input-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.input-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.crossover-panel {
position: relative;
height: 100%;
aspect-ratio: 0.28;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.crossover-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.crossover-panel__select {
flex: 0 0 auto;
width: 85%;
font-family: var(--font-ui);
font-size: 3cqh;
padding: 1cqh;
background: var(--bg-panel);
color: inherit;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
}
.gain-meter-panel {
container-type: size;
position: relative;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.gain-meter-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gain-meter-panel__grid {
flex: 1 1 auto;
min-height: 0;
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 {
min-width: 0;
min-height: 0;
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;
justify-content: stretch;
}
@@ -1,504 +0,0 @@
// InputSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
// if the actual enum differs.
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import { CrossoverFilter } from '../../../types/dsp408';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import Button from '../../Button/Button';
import MeterBar from '../../MeterBar/MeterBar';
const FREQ_MIN = 20;
const FREQ_MAX = 20000;
const FREQ_STEP = 1;
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
];
const PEQ_SLIDER_PARAMS = [
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
] as const;
type SetPEQFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
type PeqParamKey = 'frequency' | 'q' | 'gain';
type PEQValues = {
frequency: number;
q: number;
gain: number;
filter_type: PEQFilter;
bypass: boolean;
};
function PeqPanelSliders({
channel,
dsp,
band,
setPEQ,
// onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
band: PEQBand;
setPEQ: SetPEQFn;
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => {
const next = { ...peq, ...overrides };
return setPEQ(
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
};
return (
<div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div>
<div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => (
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={peq[param.key]}
// onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
function CrossoverPanel({
channel,
dsp,
title,
setCrossover,
}: {
channel: Channel;
dsp: DSP;
title: string;
setCrossover: (
channel: Channel,
frequency: number,
filter_type: CrossoverFilter,
bypass: boolean
) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover;
const frequency = crossover.high_pass.frequency;
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const next = { frequency, filter_type, bypass, ...overrides };
return setCrossover(channel, next.frequency, next.filter_type, next.bypass);
};
return (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={20}
max={20000}
step={() => 1}
unit=" Hz"
value={frequency}
onChange={(value) => commit({ frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<select
className="crossover-panel__select"
value={filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={() => commit({ bypass: !bypass })}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
}
function GainMeterPanel({
dsp,
channel,
title,
meter,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: Channel;
title: string;
meter: Meter;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const channelState =
'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 inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
return (
<div className="gain-meter-panel">
<div className="gain-meter-panel__title">{title}</div>
<div className="gain-meter-panel__grid">
<div className="gain-meter-panel__cell gain-meter-panel__cell--slider">
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
style={{ width: '100%', height: '100%' }}
/>
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--meter">
<MeterBar label="" meter={meter} />
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{
width: '100%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '3.5cqh',
paddingTop: '1cqh',
paddingBottom: '1cqh',
minHeight: 0,
}}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</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>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
type setPeqBandFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
function InputSection({
dsp,
channel,
set_high_pass: setHighPass,
set_low_pass,
setPeq,
// setPeqBypass,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: InputChannel;
set_high_pass: SetFilterFn;
set_low_pass: SetFilterFn;
setPeq: setPeqBandFn;
// setPeqBypass;
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 [liveHpFreq, setLiveHpFreq] = useState<number>(19.7);
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({});
const inputState = dsp.state.current_config.input_states[channel];
const hp = inputState.crossover.high_pass;
const lp = inputState.crossover.low_pass;
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues);
const bypassed = hp.slope === CrossoverFilter.Bypass;
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope;
// Remembers the last non-bypass slope so the Bypass button can restore it.
// const lastNonBypassType = useRef<CrossoverFilter>(
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency;
const lpFreq = liveLpFreq ?? lp.frequency;
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_high_pass({ Input: channel }, freq, filterType);
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_low_pass({ Input: channel }, freq, filterType);
// if (success) setLiveLpFreq(null);
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => {
// // Applying a slope change always takes the crossover out of bypass.
// void commitHighPass(hpFreq, filterType);
// void commitLowPass(lpFreq, filterType);
// };
// const handleBypassToggle = () => {
// if (bypassed) {
// const restore = lastNonBypassType.current;
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore);
// } else {
// void commitHighPass(hpFreq, CrossoverFilter.Bypass);
// void commitLowPass(lpFreq, CrossoverFilter.Bypass);
// }
// };
return (
<>
<div className="input-graph-section">
<div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined,
same pattern as LimiterSection's `draw` prop. */}
<LinearGraph
xMin={20}
xMax={20000}
yMin={-30}
yMax={6}
xLabel="Hz"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={() => {}}
/>
</div>
</div>
<div className="input-control-section">
<div className="input-control-panel">
<ChannelGroup title="PEQ">
<PeqPanelSliders channel={channel} dsp={dsp} band={PEQBand.B1} setPEQ={setPeq} />
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="LowPass"
setCrossover={setHighPass}
/>
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="HighPass"
setCrossover={setHighPass}
/>
</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__cell input-control-panel__cell--high-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={hpFreq}
// disabled={bypassed}
onInput={(value) => setLiveHpFreq(value)}
onChange={(value) => commitHighPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">High Pass</div>
</div>
<div className="input-control-panel__cell input-control-panel__cell--low-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={lpFreq}
// disabled={bypassed}
onInput={(value) => setLiveLpFreq(value)}
onChange={(value) => commitLowPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">Low Pass</div>
</div>
</div>
<div className="input-control-panel__side">
<label className="input-control-panel__select">
<span className="input-control-panel__select-label">Filter Type</span>
<select
value={activeFilterType}
disabled={bypassed}
onChange={(e) => handleFilterTypeChange(e.target.value as CrossoverFilter)}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
className={`input-control-panel__bypass-btn${
bypassed ? ' input-control-panel__bypass-btn--active' : ''
}`}
onClick={handleBypassToggle}
>
{bypassed ? 'Bypassed' : 'Bypass'}
</button>
</div> */}
</div>
</div>
</>
);
}
export default InputSection;
@@ -1,171 +0,0 @@
.limiter-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.limiter-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.limiter-panel {
position: relative;
height: 10;
aspect-ratio: 0.5;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.limiter-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.limiter-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'threshold attack'
'threshold release';
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.limiter-panel__cell--threshold {
grid-area: threshold;
}
.limiter-panel__cell--attack {
grid-area: attack;
}
.limiter-panel__cell--release {
grid-area: release;
}
.limiter-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.limiter-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.limiter-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.limiter-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.limiter-meter-panel {
flex: 0 0 auto;
width: fit-content;
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.limiter-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.limiter-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1cqh;
padding: 1cqh;
}
.limiter-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -1,238 +0,0 @@
// LimiterSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './LimiterSection.css';
const LIMITER_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Same mapping as CompressorSection — adjust if
// dsp.meters.channels ordering differs.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type LimiterParamKey = 'threshold' | 'attack' | 'release';
type LimiterValues = {
threshold: number;
attack: number;
release: number;
};
type SetLimiterFn = (
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
) => Promise<boolean>;
function LimiterPanel({
channel,
dsp,
setLimiter,
onLiveValue,
}: {
channel: OutputChannel;
dsp: DSP;
setLimiter: SetLimiterFn;
onLiveValue: (channel: OutputChannel, key: LimiterParamKey, value: number) => void;
}) {
const outputState = dsp.state.current_config.output_states[channel];
// ASSUMPTION: limiter settings live at output_states[channel].limiter
const lim = outputState.limiter as LimiterValues;
const commit = (overrides: Partial<LimiterValues>) => {
const next = { ...lim, ...overrides };
return setLimiter(channel, next.threshold, next.attack, next.release);
};
return (
<div className="limiter-panel">
<div className="limiter-panel__title">{outputState.name}</div>
<div className="limiter-panel__grid">
{LIMITER_SLIDER_PARAMS.map((param) => (
<div className={`limiter-panel__cell limiter-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={lim[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<LimiterValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="limiter-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
// Hard-knee brickwall limiter transfer curve, in dB.
function limiterOutputDb(input: number, threshold: number): number {
return input <= threshold ? input : threshold;
}
function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimiterFn }) {
const outputs = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const [activeChannel, setActiveChannel] = useState<OutputChannel>(outputs[0]);
const [liveLim, setLiveLim] = useState<Partial<Record<OutputChannel, LimiterValues>>>({});
const handleLiveValue = (channel: OutputChannel, key: LimiterParamKey, value: number) => {
setActiveChannel(channel);
setLiveLim((prev) => {
const base =
prev[channel] ?? (dsp.state.current_config.output_states[channel].limiter as LimiterValues);
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetLimiter: SetLimiterFn = async (channel, threshold, attack, release) => {
setActiveChannel(channel);
const success = await set_limiter(channel, threshold, attack, release);
if (success) {
setLiveLim((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeOutputState = dsp.state.current_config.output_states[activeChannel];
const activeLim = liveLim[activeChannel] ?? (activeOutputState.limiter as LimiterValues);
const curvePoints = useMemo(() => {
const { threshold } = activeLim;
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps;
points.push([x, limiterOutputDb(x, threshold)]);
}
return points;
}, [activeLim.threshold]);
return (
<>
<div className="limiter-linear-graph-section">
<div className="limiter-graph-panel">
<div className="limiter-graph__title">{activeOutputState.name} - Limiter</div>
<LinearGraph
xMin={-60}
xMax={0}
yMin={-60}
yMax={0}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
// Unity reference line
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60);
const [ux1, uy1] = toPx(0, 0);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
ctx.stroke();
ctx.setLineDash([]);
// Threshold marker
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeLim.threshold, -60);
const [tx1, ty1] = toPx(activeLim.threshold, 0);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
ctx.stroke();
ctx.setLineDash([]);
// Limiter curve
ctx.strokeStyle = '#f87171';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
curvePoints.forEach(([x, y], i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="limiter-meter-panel">
<div className="limiter-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
);
})}
</div>
</div>
</div>
<div className="limiter-section">
<div className="limiter-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<LimiterPanel
key={String(output)}
channel={output}
dsp={dsp}
setLimiter={handleSetLimiter}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default LimiterSection;
@@ -1,171 +0,0 @@
.matrix-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: stretch;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
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;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.matrix-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
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;
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);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
overflow: hidden;
}
.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;
text-align: center;
font-size: 2.5cqh;
padding: 0.5cqh;
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;
min-height: 0;
border: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: none;
}
.matrix-cell:focus-visible {
outline: 2px solid var(--accent-brand);
outline-offset: -2px;
}
.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;
}
@@ -1,257 +0,0 @@
// MatrixSection.tsx
import { Fragment, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
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));
}
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 {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs?.includes(input) ?? false;
}
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;
}
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>
);
}
function MatrixSection({
dsp,
setMatrixRouting,
setMatrixGain,
}: {
dsp: DSP;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (
inputChannel: InputChannel,
outputChannel: OutputChannel,
gain: number
) => Promise<boolean>;
}) {
// 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 handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
void setMatrixGain(input, output, gain);
};
return (
<>
<div className="matrix-graph-section">
<div className="matrix-graph-panel">Routing overview — see chat for ideas</div>
</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">
{dsp.state.current_config.output_states[output].name}
</div>
{INPUTS.map((input) => {
const connected = isRouted(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)}
/>
);
})}
</Fragment>
))}
</div>
</div>
</div>
</>
);
}
export default MatrixSection;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types/types';
import { DSP, AppPage } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon';