refactor: folders/files to group dsp logic
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user