382 lines
12 KiB
TypeScript
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;
|