refactor: folders/files to group dsp logic
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// CompressorSection.tsx
|
||||
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 './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;
|
||||
@@ -0,0 +1,107 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DSP } from '../../../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
import Led from '../../../../components/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;
|
||||
@@ -0,0 +1,31 @@
|
||||
.dsp-content {
|
||||
position: fixed;
|
||||
left: var(--sidebar-width-collapsed);
|
||||
right: 0;
|
||||
top: var(--header-height);
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-height: 0;
|
||||
|
||||
transition: left var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.app-root:has(.sidebar-rail--expanded) .dsp-content {
|
||||
left: var(--sidebar-width-expanded);
|
||||
}
|
||||
|
||||
.dsp-loading-overlay {
|
||||
position: fixed;
|
||||
left: var(--sidebar-width-collapsed);
|
||||
right: 0;
|
||||
top: var(--header-height, 0);
|
||||
bottom: 0;
|
||||
transition: left var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
||||
left: var(--sidebar-width-expanded);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { DSP } from '../../../../types';
|
||||
import Sidebar from '../../../../components/Sidebar/Sidebar';
|
||||
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
||||
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||
import GainSection from '../GainSection/GainSection';
|
||||
import CompressorSection from '../CompressorSection/CompressorSection';
|
||||
import GateSection from '../GateSection/GateSection';
|
||||
import LimiterSection from '../LimiterSection/LimiterSection';
|
||||
import DelaySection from '../DelaySection/DelaySection';
|
||||
import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay';
|
||||
import { InputChannel, OutputChannel } from '../../types';
|
||||
import MatrixSection from '../Matrixsection/Matrixsection';
|
||||
import GeqSection from '../GeqSection/GeqSection';
|
||||
import InputSection from '../InputSection/InputSection';
|
||||
import { DSP408Api } from '../../dsp408';
|
||||
import './DSP408Panel.css';
|
||||
|
||||
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
|
||||
|
||||
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
const [activeSidebar, setActiveSidebar] = useState('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 = [
|
||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
||||
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
||||
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
|
||||
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
|
||||
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
|
||||
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
|
||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
|
||||
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
|
||||
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> },
|
||||
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
|
||||
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
|
||||
{
|
||||
id: 'out1',
|
||||
label: `${outputStates[OutputChannel.Out1].name}`,
|
||||
icon: <TextIcon>Out 1</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out2',
|
||||
label: `${outputStates[OutputChannel.Out2].name}`,
|
||||
icon: <TextIcon>Out 2</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out3',
|
||||
label: `${outputStates[OutputChannel.Out3].name}`,
|
||||
icon: <TextIcon>Out 3</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out4',
|
||||
label: `${outputStates[OutputChannel.Out4].name}`,
|
||||
icon: <TextIcon>Out 4</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out5',
|
||||
label: `${outputStates[OutputChannel.Out5].name}`,
|
||||
icon: <TextIcon>Out 5</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out6',
|
||||
label: `${outputStates[OutputChannel.Out6].name}`,
|
||||
icon: <TextIcon>Out 6</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out7',
|
||||
label: `${outputStates[OutputChannel.Out7].name}`,
|
||||
icon: <TextIcon>Out 7</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out8',
|
||||
label: `${outputStates[OutputChannel.Out8].name}`,
|
||||
icon: <TextIcon>Out 8</TextIcon>,
|
||||
},
|
||||
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
|
||||
];
|
||||
|
||||
const presets = dsp.state?.presets.names
|
||||
? [
|
||||
{ id: 'f00', name: 'Factory Preset' },
|
||||
...dsp.state.presets.names.map((name, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name,
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{ id: 'f00', name: 'Factory Preset' },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name: 'Default Preset',
|
||||
})),
|
||||
];
|
||||
|
||||
const onAppChange = async (id: string) => {
|
||||
const presetIndex = presets.findIndex((preset) => preset.id === id);
|
||||
if (presetIndex === -1) return;
|
||||
|
||||
setPresetLoading(true);
|
||||
const success = await api.recallPreset(presetIndex);
|
||||
if (success) setSelectedPreset(presetIndex);
|
||||
setPresetLoading(false);
|
||||
};
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeSidebar) {
|
||||
case 'overview':
|
||||
return <ConnectionPanel dsp={dsp} onConnect={api.connect} onDisconnect={api.disconnect} />;
|
||||
|
||||
case 'gain':
|
||||
return (
|
||||
<GainSection
|
||||
dsp={dsp}
|
||||
setGain={api.setGain}
|
||||
setMute={api.setMute}
|
||||
setInverse={api.setInverse}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'gate':
|
||||
return <GateSection dsp={dsp} set_gate={api.setGate} />;
|
||||
|
||||
case 'compressor':
|
||||
return <CompressorSection dsp={dsp} set_compressor={api.setCompressor} />;
|
||||
|
||||
case 'limiter':
|
||||
return <LimiterSection dsp={dsp} set_limiter={api.setLimiter} />;
|
||||
|
||||
case 'delay':
|
||||
return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />;
|
||||
|
||||
case 'ina':
|
||||
return (
|
||||
<InputSection
|
||||
dsp={dsp}
|
||||
channel={InputChannel.InA}
|
||||
set_low_pass={api.setLowPass}
|
||||
set_high_pass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setGain={api.setGain}
|
||||
setMute={api.setMute}
|
||||
setInverse={api.setInverse}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'matrix':
|
||||
return (
|
||||
<MatrixSection
|
||||
dsp={dsp}
|
||||
setMatrixRouting={api.setMatrixRouting}
|
||||
setMatrixGain={api.setMatrixGain}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'geq':
|
||||
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
||||
<Sidebar
|
||||
items={sidebarItems}
|
||||
activeId={activeSidebar}
|
||||
onSelect={setActiveSidebar}
|
||||
appName={dsp.name}
|
||||
appOptions={presets}
|
||||
selectedAppId={presets[selectedPreset]?.id}
|
||||
onAppChange={onAppChange}
|
||||
onAppRename={api.setCurrentPresetName}
|
||||
expanded={sidebarExpanded}
|
||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||
/>
|
||||
|
||||
<LoadingOverlay
|
||||
active={presetLoading}
|
||||
label="Recalling preset…"
|
||||
overlayClassName="dsp-loading-overlay"
|
||||
>
|
||||
<div className="dsp-content">{renderTab()}</div>
|
||||
</LoadingOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DSP408Panel;
|
||||
@@ -0,0 +1,117 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { InputChannel, Channel, OutputChannel, DelayUnit } 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 { useMemo, useState } from 'react';
|
||||
import './DelaySection.css';
|
||||
import { DELAY_RANGES } from '../../types';
|
||||
|
||||
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,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
setDelay: SetDelayFn;
|
||||
setDelayUnit: (unit: DelayUnit) => Promise<boolean>;
|
||||
}) {
|
||||
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);
|
||||
};
|
||||
|
||||
const handleUnitChange = async (newUnit: DelayUnit) => {
|
||||
if (newUnit === unit) return;
|
||||
|
||||
await setDelayUnit(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;
|
||||
@@ -0,0 +1,105 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { InputChannel, Channel, OutputChannel } 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 './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;
|
||||
@@ -0,0 +1,159 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// GateSection.tsx
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { InputChannel } from '../../types';
|
||||
import { DSP408 as DSP } from '../../../../types';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import MeterBar from '../../../../components/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;
|
||||
@@ -0,0 +1,143 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { InputChannel, DiscreteFrequency } 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 { 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;
|
||||
@@ -0,0 +1,342 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// InputSection.tsx
|
||||
import VerticalSlider from '../../../../components/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';
|
||||
import { DSP408 as DSP } from '../../../../types';
|
||||
import { CrossoverFilter } from '../../types';
|
||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import { useRef, useState } from 'react';
|
||||
import './InputSection.css';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import MeterBar from '../../../../components/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;
|
||||
@@ -0,0 +1,171 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// LimiterSection.tsx
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { OutputChannel } from '../../types';
|
||||
import { DSP408 as DSP } from '../../../../types';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import MeterBar from '../../../../components/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;
|
||||
@@ -0,0 +1,171 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// MatrixSection.tsx
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { InputChannel, OutputChannel } from '../../types';
|
||||
import { DSP408 as DSP } from '../../../../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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
||||
export enum DelayUnit {
|
||||
Millisecond = 'Millisecond',
|
||||
Meter = 'Meter',
|
||||
Feet = 'Feet',
|
||||
}
|
||||
|
||||
export enum InputChannel {
|
||||
InA = 'InA',
|
||||
InB = 'InB',
|
||||
InC = 'InC',
|
||||
InD = 'InD',
|
||||
}
|
||||
|
||||
export enum OutputChannel {
|
||||
Out1 = 'Out1',
|
||||
Out2 = 'Out2',
|
||||
Out3 = 'Out3',
|
||||
Out4 = 'Out4',
|
||||
Out5 = 'Out5',
|
||||
Out6 = 'Out6',
|
||||
Out7 = 'Out7',
|
||||
Out8 = 'Out8',
|
||||
}
|
||||
|
||||
export type Channel = { Input: InputChannel } | { Output: OutputChannel };
|
||||
|
||||
export enum Ratio {
|
||||
Ratio1_1 = 'Ratio1_1',
|
||||
Ratio1_1_1 = 'Ratio1_1_1',
|
||||
Ratio1_1_3 = 'Ratio1_1_3',
|
||||
Ratio1_1_5 = 'Ratio1_1_5',
|
||||
Ratio1_1_7 = 'Ratio1_1_7',
|
||||
Ratio1_2 = 'Ratio1_2',
|
||||
Ratio1_2_5 = 'Ratio1_2_5',
|
||||
Ratio1_3 = 'Ratio1_3',
|
||||
Ratio1_3_5 = 'Ratio1_3_5',
|
||||
Ratio1_4 = 'Ratio1_4',
|
||||
Ratio1_5 = 'Ratio1_5',
|
||||
Ratio1_6 = 'Ratio1_6',
|
||||
Ratio1_8 = 'Ratio1_8',
|
||||
Ratio1_10 = 'Ratio1_10',
|
||||
Ratio1_20 = 'Ratio1_20',
|
||||
Limit = 'Limit',
|
||||
}
|
||||
|
||||
export enum CrossoverFilter {
|
||||
Bypass = 'Bypass',
|
||||
Bw6 = 'Bw6',
|
||||
Bw12 = 'Bw12',
|
||||
Bw18 = 'Bw18',
|
||||
Bw24 = 'Bw24',
|
||||
Bw30 = 'Bw30',
|
||||
Bw36 = 'Bw36',
|
||||
Bw42 = 'Bw42',
|
||||
Bw48 = 'Bw48',
|
||||
Lk12 = 'Lk12',
|
||||
Lk24 = 'Lk24',
|
||||
Lk36 = 'Lk36',
|
||||
Lk48 = 'Lk48',
|
||||
}
|
||||
|
||||
export enum PEQFilter {
|
||||
Peak = 'Peak',
|
||||
LowShelf = 'LowShelf',
|
||||
HighShelf = 'HighShelf',
|
||||
Lp6Db = 'Lp6Db',
|
||||
Lp12Db = 'Lp12Db',
|
||||
Hp6Db = 'Hp6Db',
|
||||
Hp12Db = 'Hp12Db',
|
||||
AllPass1 = 'AllPass1',
|
||||
AllPass2 = 'AllPass2',
|
||||
}
|
||||
|
||||
export enum DiscreteFrequency {
|
||||
F20 = 'F20',
|
||||
F25 = 'F25',
|
||||
F31_5 = 'F31_5',
|
||||
F40 = 'F40',
|
||||
F50 = 'F50',
|
||||
F63 = 'F63',
|
||||
F80 = 'F80',
|
||||
F100 = 'F100',
|
||||
F125 = 'F125',
|
||||
F160 = 'F160',
|
||||
F200 = 'F200',
|
||||
F250 = 'F250',
|
||||
F315 = 'F315',
|
||||
F400 = 'F400',
|
||||
F500 = 'F500',
|
||||
F630 = 'F630',
|
||||
F800 = 'F800',
|
||||
F1000 = 'F1000',
|
||||
F1250 = 'F1250',
|
||||
F1600 = 'F1600',
|
||||
F2000 = 'F2000',
|
||||
F2500 = 'F2500',
|
||||
F3150 = 'F3150',
|
||||
F4000 = 'F4000',
|
||||
F5000 = 'F5000',
|
||||
F6300 = 'F6300',
|
||||
F8000 = 'F8000',
|
||||
F10000 = 'F10000',
|
||||
F12500 = 'F12500',
|
||||
F16000 = 'F16000',
|
||||
F20000 = 'F20000',
|
||||
}
|
||||
|
||||
export enum PEQBand {
|
||||
B1 = 1,
|
||||
B2 = 2,
|
||||
B3 = 3,
|
||||
B4 = 4,
|
||||
B5 = 5,
|
||||
B6 = 6,
|
||||
B7 = 7,
|
||||
B8 = 8,
|
||||
B9 = 9,
|
||||
}
|
||||
|
||||
// ---------------- STATE ----------------
|
||||
|
||||
export type Gate = {
|
||||
attack: number;
|
||||
release: number;
|
||||
hold: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type Compressor = {
|
||||
threshold: number;
|
||||
ratio: Ratio;
|
||||
attack: number;
|
||||
release: number;
|
||||
knee: number;
|
||||
};
|
||||
|
||||
export type Limiter = {
|
||||
threshold: number;
|
||||
attack: number;
|
||||
release: number;
|
||||
};
|
||||
|
||||
export type GraphicEQ = {
|
||||
gains: Record<DiscreteFrequency, number>;
|
||||
bypass: boolean;
|
||||
};
|
||||
|
||||
export type PEQ = {
|
||||
gain: number;
|
||||
frequency: number;
|
||||
q: number;
|
||||
filter_type: PEQFilter;
|
||||
bypass: boolean;
|
||||
};
|
||||
|
||||
export type Crossover = {
|
||||
frequency: number;
|
||||
slope: CrossoverFilter;
|
||||
};
|
||||
|
||||
export type CrossoverFilters = {
|
||||
high_pass: Crossover;
|
||||
low_pass: Crossover;
|
||||
};
|
||||
|
||||
export type MatrixRoutes = {
|
||||
connected: InputChannel[];
|
||||
gains: number[];
|
||||
};
|
||||
|
||||
export type PEQChain = {
|
||||
bands: Record<PEQBand, PEQ>;
|
||||
bypass: boolean;
|
||||
};
|
||||
|
||||
export type PresetBank = {
|
||||
current_index: number;
|
||||
names: string[];
|
||||
modified: boolean[];
|
||||
};
|
||||
|
||||
export type DeviceFlags = {
|
||||
is_locked: boolean;
|
||||
};
|
||||
|
||||
export type InputSource = {
|
||||
type: string;
|
||||
frequency: string;
|
||||
};
|
||||
|
||||
export type InputChannelState = {
|
||||
name: string;
|
||||
gain: number;
|
||||
phase_inverted: boolean;
|
||||
delay: number;
|
||||
|
||||
gate: Gate;
|
||||
geq: GraphicEQ;
|
||||
peq_chain: PEQChain;
|
||||
crossover: CrossoverFilters;
|
||||
|
||||
linked_channels: Channel[];
|
||||
|
||||
mute: boolean;
|
||||
};
|
||||
|
||||
export type OutputChannelState = {
|
||||
name: string;
|
||||
|
||||
matrix_routes: MatrixRoutes;
|
||||
crossover: CrossoverFilters;
|
||||
peq_chain: PEQChain;
|
||||
|
||||
compressor: Compressor;
|
||||
limiter: Limiter;
|
||||
|
||||
gain: number;
|
||||
phase_inverted: boolean;
|
||||
delay: number;
|
||||
|
||||
linked_channels: Channel[];
|
||||
|
||||
mute: boolean;
|
||||
};
|
||||
|
||||
export type DSPConfigState = {
|
||||
input_states: Record<InputChannel, InputChannelState>;
|
||||
output_states: Record<OutputChannel, OutputChannelState>;
|
||||
|
||||
input_source: InputSource;
|
||||
delay_unit: DelayUnit;
|
||||
};
|
||||
|
||||
export type DSPState = {
|
||||
name: string;
|
||||
flags: DeviceFlags;
|
||||
presets: PresetBank;
|
||||
current_config: DSPConfigState;
|
||||
};
|
||||
|
||||
export interface Meter {
|
||||
rms: number;
|
||||
peak: number;
|
||||
clip: boolean;
|
||||
}
|
||||
|
||||
export interface Meters {
|
||||
channels: Meter[];
|
||||
}
|
||||
|
||||
/* -- Default Value -- */
|
||||
|
||||
const createDefaultGate = (): Gate => ({
|
||||
attack: 10,
|
||||
release: 10,
|
||||
hold: 10,
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
const createDefaultGraphicEQ = (): GraphicEQ => ({
|
||||
gains: {
|
||||
[DiscreteFrequency.F20]: 0,
|
||||
[DiscreteFrequency.F25]: 0,
|
||||
[DiscreteFrequency.F31_5]: 0,
|
||||
[DiscreteFrequency.F40]: 0,
|
||||
[DiscreteFrequency.F50]: 0,
|
||||
[DiscreteFrequency.F63]: 0,
|
||||
[DiscreteFrequency.F80]: 0,
|
||||
[DiscreteFrequency.F100]: 0,
|
||||
[DiscreteFrequency.F125]: 0,
|
||||
[DiscreteFrequency.F160]: 0,
|
||||
[DiscreteFrequency.F200]: 0,
|
||||
[DiscreteFrequency.F250]: 0,
|
||||
[DiscreteFrequency.F315]: 0,
|
||||
[DiscreteFrequency.F400]: 0,
|
||||
[DiscreteFrequency.F500]: 0,
|
||||
[DiscreteFrequency.F630]: 0,
|
||||
[DiscreteFrequency.F800]: 0,
|
||||
[DiscreteFrequency.F1000]: 0,
|
||||
[DiscreteFrequency.F1250]: 0,
|
||||
[DiscreteFrequency.F1600]: 0,
|
||||
[DiscreteFrequency.F2000]: 0,
|
||||
[DiscreteFrequency.F2500]: 0,
|
||||
[DiscreteFrequency.F3150]: 0,
|
||||
[DiscreteFrequency.F4000]: 0,
|
||||
[DiscreteFrequency.F5000]: 0,
|
||||
[DiscreteFrequency.F6300]: 0,
|
||||
[DiscreteFrequency.F8000]: 0,
|
||||
[DiscreteFrequency.F10000]: 0,
|
||||
[DiscreteFrequency.F12500]: 0,
|
||||
[DiscreteFrequency.F16000]: 0,
|
||||
[DiscreteFrequency.F20000]: 0,
|
||||
},
|
||||
bypass: false,
|
||||
});
|
||||
|
||||
const createDefaultPEQ = (): PEQ => ({
|
||||
gain: 0,
|
||||
frequency: 1000,
|
||||
q: 1,
|
||||
filter_type: PEQFilter.Peak,
|
||||
bypass: false,
|
||||
});
|
||||
|
||||
const createDefaultPEQChain = (): PEQChain => ({
|
||||
bands: Object.fromEntries(
|
||||
Object.values(PEQBand)
|
||||
.filter((band): band is PEQBand => typeof band === 'number')
|
||||
.map((band) => [band, createDefaultPEQ()])
|
||||
) as Record<PEQBand, PEQ>,
|
||||
bypass: false,
|
||||
});
|
||||
|
||||
const createDefaultCrossover = (): Crossover => ({
|
||||
frequency: 20000,
|
||||
slope: CrossoverFilter.Bypass,
|
||||
});
|
||||
|
||||
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
|
||||
high_pass: createDefaultCrossover(),
|
||||
low_pass: createDefaultCrossover(),
|
||||
});
|
||||
|
||||
const createDefaultInputChannelState = (name: string): InputChannelState => ({
|
||||
name,
|
||||
gain: 0,
|
||||
phase_inverted: false,
|
||||
delay: 0,
|
||||
|
||||
gate: createDefaultGate(),
|
||||
geq: createDefaultGraphicEQ(),
|
||||
peq_chain: createDefaultPEQChain(),
|
||||
crossover: createDefaultCrossoverFilters(),
|
||||
|
||||
linked_channels: [],
|
||||
|
||||
mute: false,
|
||||
});
|
||||
|
||||
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
|
||||
name,
|
||||
|
||||
matrix_routes: {
|
||||
connected: [],
|
||||
gains: [],
|
||||
},
|
||||
|
||||
crossover: createDefaultCrossoverFilters(),
|
||||
peq_chain: createDefaultPEQChain(),
|
||||
|
||||
compressor: {
|
||||
threshold: -90,
|
||||
ratio: Ratio.Ratio1_1,
|
||||
attack: 1,
|
||||
release: 10,
|
||||
knee: 0,
|
||||
},
|
||||
|
||||
limiter: {
|
||||
threshold: -90,
|
||||
attack: 1,
|
||||
release: 10,
|
||||
},
|
||||
|
||||
gain: 0,
|
||||
phase_inverted: false,
|
||||
delay: 0,
|
||||
|
||||
linked_channels: [],
|
||||
|
||||
mute: false,
|
||||
});
|
||||
|
||||
export const defaultDSPState: DSPState = {
|
||||
name: 'Default',
|
||||
flags: {
|
||||
is_locked: false,
|
||||
},
|
||||
|
||||
presets: {
|
||||
current_index: 0,
|
||||
names: Array(20).fill('Default'),
|
||||
modified: Array(20).fill(false),
|
||||
},
|
||||
|
||||
current_config: {
|
||||
input_source: {
|
||||
type: 'Analog',
|
||||
frequency: '48kHz',
|
||||
},
|
||||
|
||||
delay_unit: DelayUnit.Millisecond,
|
||||
|
||||
input_states: {
|
||||
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
|
||||
[InputChannel.InB]: createDefaultInputChannelState('In B'),
|
||||
[InputChannel.InC]: createDefaultInputChannelState('In C'),
|
||||
[InputChannel.InD]: createDefaultInputChannelState('In D'),
|
||||
},
|
||||
|
||||
output_states: {
|
||||
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
|
||||
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
|
||||
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
|
||||
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
|
||||
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
|
||||
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
|
||||
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
|
||||
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createDefaultMeter = (): Meter => ({
|
||||
rms: -60,
|
||||
peak: -60,
|
||||
clip: false,
|
||||
});
|
||||
|
||||
export const defaultMeters: Meters = {
|
||||
channels: Array.from({ length: 12 }, createDefaultMeter),
|
||||
};
|
||||
|
||||
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
|
||||
[DelayUnit.Millisecond]: [0, 680.0],
|
||||
[DelayUnit.Meter]: [0, 233.58],
|
||||
[DelayUnit.Feet]: [0, 766.329],
|
||||
};
|
||||
Reference in New Issue
Block a user