Files
dsp-thomann-interface/src/features/dsp408/components/CompressorSection/CompressorSection.tsx
T
2026-08-12 18:45:57 +02:00

382 lines
12 KiB
TypeScript

// CompressorSection.tsx
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } 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 ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
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 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>
);
}
// Compressor transfer curve, in dB.
//
// The knee is NOT a smooth/quadratic blend. It is three straight
// (piecewise-linear) segments:
// 1) input <= kneeStart -> 1:1 line (output = input)
// 2) kneeStart < input < kneeEnd -> a straight line connecting the
// point on segment (1) at kneeStart
// to the point on segment (3) at kneeEnd
// 3) input >= kneeEnd -> ratio line (output = threshold + (input-threshold)/ratio)
//
// kneeStart/kneeEnd are simply the threshold shifted down/up by half the
// knee width, so increasing "knee" widens the middle straight segment
// symmetrically around the threshold.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2;
// No knee: simple two-segment (hard-knee) curve.
if (knee <= 0) {
return input <= threshold ? input : threshold + (input - threshold) / ratio;
}
// Segment 1: below the knee, straight 1:1 line.
if (input <= kneeStart) {
return input;
}
// Segment 3: above the knee, straight ratio line.
if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio;
}
// Segment 2: straight line between the two boundary points
// (kneeStart, kneeStart) and (kneeEnd, threshold + (kneeEnd - threshold) / ratio).
const y1 = kneeStart;
const y2 = threshold + (kneeEnd - threshold) / ratio;
const t = (input - kneeStart) / (kneeEnd - kneeStart);
return y1 + t * (y2 - y1);
}
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 = -100 + (100 * 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={-100}
xMax={0}
yMin={-100}
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(-100, -100);
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, -100);
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;