// 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'; import { RATIO_ORDER, RATIO_LABEL, RATIO_NUMERIC, KNEE_VALUES } from '../../constants'; 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; 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; 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) => { const next = { ...comp, ...overrides }; return setCompressor(channel, next.ratio, next.attack, next.release, next.knee, next.threshold); }; return (
{outputState.name}
{COMPRESSOR_SLIDER_PARAMS.map((param) => (
param.step} unit={param.unit} value={comp[param.key]} onInput={(value) => onLiveValue(channel, param.key, value)} onChange={(value) => commit({ [param.key]: value } as Partial)} style={{ width: '100%', flex: '1 1 auto' }} />
{param.label}
))}
RATIO_LABEL[v]} onChange={(value) => { onLiveValue(channel, 'ratio', value); commit({ ratio: value }); }} />
Ratio
`${v} dB`} onChange={(value) => { onLiveValue(channel, 'knee', value); commit({ knee: value }); }} />
Knee
); } // 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(outputs[0]); const [liveComp, setLiveComp] = useState>>({}); 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 ( <>
{activeOutputState.name} - RMS Compressor
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(); }} />
{outputs.map((output) => { const outputState = dsp.state.current_config.output_states[output]; return ( ); })}
{outputs.map((output) => ( ))}
); } export default CompressorSection;