Files
dsp-thomann-interface/src/features/dsp408/components/ChannelSection/ChannelSection.tsx
T
2026-10-02 22:48:47 +02:00

772 lines
23 KiB
TypeScript

// ChannelSection.tsx
import VerticalSlider, {
makeLogStep,
makeLogQuantize,
} from '../../../../components/VerticalSlider/VerticalSlider';
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import {
CROSSOVER_FILTER_LABEL,
PEQ_BANDS_ORDER,
PEQ_FILTERS_LABEL,
PEQ_Q_RANGES,
PEAK_Q_VALUES,
PEQ_Q_UNUSED,
PEQ_GAIN_USED,
} from '../../constants';
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types';
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState, useEffect } from 'react';
import './ChannelSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button';
import {
FREQUENCY_MAX,
FREQUENCY_MIN,
FREQUENCY_QUANTIZATION,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
GRAPH_Y_MIN,
} from '../../constants';
import { drawCurves } from '../../utils/graph/draw';
import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq';
import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve';
import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover';
function nearestQValue(q: number, values: readonly number[] = PEAK_Q_VALUES): number {
return values.reduce((nearest, current) =>
Math.abs(current - q) < Math.abs(nearest - q) ? current : nearest
);
}
type GraphView = 'both' | 'magnitude' | 'phase';
function GraphViewToggle({
view,
onChange,
}: {
view: GraphView;
onChange: (v: GraphView) => void;
}) {
const options: { value: GraphView; label: string }[] = [
{ value: 'both', label: 'Both' },
{ value: 'magnitude', label: 'Mag' },
{ value: 'phase', label: 'Phase' },
];
return (
<div className="channel-graph-view-toggle">
{options.map((opt) => (
<button
key={opt.value}
type="button"
className={`channel-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
function ChannelActions({
bypass,
resetting,
onBypass,
onReset,
}: {
bypass: boolean;
resetting: boolean;
onBypass: () => void;
onReset: () => void;
}) {
return (
<div className="geq-channel-selector">
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
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'}
</Button>
</div>
);
}
function PeqPanelSliders({
peq,
setPEQ,
onLiveValue,
}: {
peq: PEQ;
setPEQ: (
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
onLiveValue: (value: PEQ) => void;
}) {
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
const nearestQ = nearestQValue(peq.q);
const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides };
return setPEQ(next.gain, next.frequency, overrides.q ?? nearestQ, next.filter_type, next.bypass);
};
return (
<div className="channel-panel">
<div className="channel-panel__title">PEQ Parameters</div>
<div className="channel-panel__grid">
<div className="channel-panel__cell channel-panel__cell--frequency">
<VerticalSlider
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
scale="log"
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
value={peq.frequency}
onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue({ ...peq, frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Frequency</div>
</div>
<div className="channel-panel__cell channel-panel__cell--q">
<VerticalSlider
min={qMin}
max={qMax}
values={PEAK_Q_VALUES}
formatValue={(v) => v.toFixed(2)}
value={nearestQ}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: nearestQValue(value) })}
disabled={qDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Q</div>
</div>
<div className="channel-panel__cell channel-panel__cell--gain">
<VerticalSlider
min={-12}
max={12}
step={() => 0.1}
unit="dB"
value={peq.gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => commit({ gain: value })}
onInput={(value) => onLiveValue({ ...peq, gain: value })}
disabled={gainDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Gain</div>
</div>
</div>
</div>
);
}
function CrossoverPanel({
crossover,
title,
setCrossover,
onLiveValue,
}: {
crossover: Crossover;
title: string;
setCrossover: (frequency: number, filter_type: CrossoverFilter) => Promise<boolean>;
onLiveValue: (value: number) => void;
}) {
const frequency = crossover.frequency;
const filter_type = crossover.slope;
const bypass = filter_type === CrossoverFilter.Bypass;
const lastFilterType = useRef<CrossoverFilter>(
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
);
const commit = async (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const nextFrequency = overrides.frequency ?? frequency;
const selectedFilterType =
overrides.filter_type !== undefined
? overrides.filter_type
: bypass
? lastFilterType.current
: filter_type;
// Always remember the latest non-bypass selection.
if (selectedFilterType !== CrossoverFilter.Bypass) {
lastFilterType.current = selectedFilterType;
}
const nextBypass = overrides.bypass ?? bypass;
const nextFilterType = nextBypass ? CrossoverFilter.Bypass : selectedFilterType;
return setCrossover(nextFrequency, nextFilterType);
};
return (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
scale="log"
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
value={frequency}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue(value)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<ChoiceSelect
className="crossover-panel__select"
value={bypass ? lastFilterType.current : filter_type}
options={CROSSOVER_FILTER_LABEL.map((opt) => opt.value)}
formatLabel={(v) =>
CROSSOVER_FILTER_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
}
onChange={(value) => commit({ filter_type: value })}
/>
<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 PeqBandList({
bands,
liveBands,
activeBand,
setActiveBand,
setPEQ,
bandsOrder,
}: {
bands: Record<PEQBand, PEQ>;
liveBands: Partial<Record<PEQBand, PEQ>>;
activeBand: PEQBand;
setActiveBand: (band: PEQBand) => void;
setPEQ: (
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
bandsOrder: PEQBand[];
}) {
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const live = liveBands[band] ?? bands[band];
const next = { ...live, ...overrides };
return setPEQ(
band,
next.gain,
next.frequency,
nearestQValue(next.q),
next.filter_type,
next.bypass
);
};
return (
<div className="peq-band-list">
<div className="peq-band-list__row peq-band-list__row--header">
<div className="peq-band-list__cell">Band</div>
<div className="peq-band-list__cell">Freq</div>
<div className="peq-band-list__cell">Q</div>
<div className="peq-band-list__cell">Gain</div>
<div className="peq-band-list__cell">Type</div>
<div className="peq-band-list__cell">Bypass</div>
</div>
{bandsOrder.map((band, i) => {
const peq = liveBands[band] ?? bands[band];
const isActive = band === activeBand;
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
const qValue = nearestQValue(peq.q, qValues.length ? qValues : PEAK_Q_VALUES);
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
return (
<div
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
key={band}
>
<Button
variant="toggle"
className={isActive ? 'active-info' : ''}
onClick={() => setActiveBand(band)}
style={{
minHeight: 0,
fontSize: '4cqh',
}}
>
{`B${i + 1}`}
</Button>
{/* Frequency */}
<ChoiceStepper
className="peq-band-list__stepper"
value={peq.frequency}
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
step={FREQUENCY_QUANTIZATION}
formatLabel={(v) =>
v >= 1000 ? `${(v / 1000).toFixed(2)} kHz` : `${v.toFixed(1)} Hz`
}
onChange={(value) => commit(band, { frequency: value })}
/>
<ChoiceStepper
className="peq-band-list__stepper"
value={qValue}
values={qValues}
formatLabel={(v) => v.toFixed(2)}
disabled={qDisabled}
onChange={(value) => commit(band, { q: value })}
/>
<ChoiceStepper
className="peq-band-list__stepper"
value={peq.gain}
min={-12}
max={12}
step={0.1}
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
disabled={gainDisabled}
onChange={(value) => commit(band, { gain: value })}
/>
<ChoiceSelect<PEQFilter>
className="peq-band-list__select"
value={peq.filter_type}
options={PEQ_FILTERS_LABEL.map((opt) => opt.value)}
formatLabel={(v) =>
PEQ_FILTERS_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
}
onChange={(value) => {
const [min, max] = PEQ_Q_RANGES[value];
const allowed = PEAK_Q_VALUES.filter((q) => q >= min && q <= max);
commit(band, {
filter_type: value,
q: nearestQValue(3.0, allowed.length ? allowed : PEAK_Q_VALUES),
});
}}
/>
<Button
variant="toggle"
className={peq.bypass ? 'active-warning' : ''}
onClick={() => commit(band, { bypass: !peq.bypass })}
style={{ minHeight: 0, fontSize: '4cqh', paddingInline: 0 }}
>
{peq.bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
})}
</div>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
function ChannelSection({
dsp,
channel,
setHighPass,
setLowPass,
setPeq,
setPeqBypass,
}: {
dsp: DSP;
channel: Channel;
setHighPass: SetFilterFn;
setLowPass: SetFilterFn;
setPeq: (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
}) {
// UI variables
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Live values
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQ>>>({});
const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
// DSP values
const isInput = 'Input' in channel;
const activeChannelState = isInput
? dsp.state.current_config.input_states[channel.Input]
: dsp.state.current_config.output_states[channel.Output];
const ActiveHighPassFilter = activeChannelState.crossover.high_pass;
const ActiveLowPassFilter = activeChannelState.crossover.low_pass;
const ActivePeq = activeChannelState.peq_chain;
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
const handleResetPeq = async () => {
if (resetting) return;
setResetting(true);
for (const band of bandsOrder) {
const current = displayPeq[band];
// Skip only if already flat — nothing to change.
if (current.gain === 0 && current.filter_type === PEQFilter.Peak) continue;
await commitPEQ(band, 0, current.frequency, nearestQValue(3.0, PEAK_Q_VALUES), PEQFilter.Peak, current.bypass);
}
setResetting(false);
};
const confirmResetPeq = async () => {
setIsResetConfirmOpen(false);
await handleResetPeq();
};
const handleBypassClick = () => {
setPeqBypass(channel, !ActivePeq.bypass);
};
// Live handlers
const handleLiveHighPassValue = (value: number) => {
setLiveHpFreq(value);
};
const handleLiveLowPassValue = (value: number) => {
setLiveLpFreq(value);
};
const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
setLivePeq((prev) => ({
...prev,
[band]: value,
}));
};
// Real dsp value handlers
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setHighPass(channel, freq, filterType);
if (success) {
setLiveHpFreq(freq);
}
return success;
};
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setLowPass(channel, freq, filterType);
if (success) {
setLiveLpFreq(freq);
}
return success;
};
const commitPEQ = async (
band: PEQBand,
gain: number,
frequency: number,
q: number,
filter_type: PEQFilter,
bypass: boolean
) => {
const success = await setPeq(channel, band, gain, frequency, q, filter_type, bypass);
if (success) {
setLivePeq((prev) => {
const next = { ...prev };
delete next[band];
return next;
});
}
return success;
};
// Diplay value (merge live and real)
const displayPeq: Record<PEQBand, PEQ> = {
...ActivePeq.bands,
...livePeq,
};
const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope };
const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope };
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return (
<>
<div className="channel-graph-section">
<div className="channel-graph-panel">
<div className="channel-graph__title-row">
<div className="channel-graph__title">{activeChannelState.name} - Filters</div>
<GraphViewToggle view={graphView} onChange={setGraphView} />
</div>
{(graphView === 'both' || graphView === 'magnitude') && (
<div className="channel-graph-panel__magnitude-wrap">
<LinearGraph
ref={magnitudeGraphRef}
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={GRAPH_Y_MIN}
yMax={GRAPH_Y_MAX}
yStep={3}
xLabel="Frequency - Hz"
yLabel="Magnituded - dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
curves: [
{
points: sumCurvePoints([
getPeqCurvePoints({ freqs, bands: displayPeq }),
getCrossoverCurvePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/>
</div>
)}
{(graphView === 'both' || graphView === 'phase') && (
<LinearGraph
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={-180}
yMax={180}
yStep={45}
xLabel="Frequency - Hz"
yLabel="Phase - Deg"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: GRAPH_Y_MIN,
yMax: GRAPH_Y_MAX,
curves: [
{
points: sumPhaseCurvePoints([
getPeqPhasePoints({ freqs, bands: displayPeq }),
getCrossoverPhasePoints({
freqs,
highPass: highPassSpec,
lowPass: lowPassSpec,
}),
]),
style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass },
},
],
});
}}
/>
)}
{ActivePeq.bypass && (
<div className="channel-bypass-overlay">
<span>BYPASSED</span>
</div>
)}
</div>
</div>
<div className="channel-control-section">
<div className="channel-control-panel">
<ChannelGroup title="PEQ">
<PeqBandList
bands={ActivePeq.bands}
liveBands={livePeq}
activeBand={activeBand}
setActiveBand={setActiveBand}
setPEQ={commitPEQ}
bandsOrder={bandsOrder}
/>
<PeqPanelSliders
peq={ActivePeq.bands[activeBand]}
setPEQ={(gain, frequency, q, filter_type, bypass) =>
commitPEQ(activeBand, gain, frequency, q, filter_type, bypass)
}
onLiveValue={(value) => handleLivePEQValue(activeBand, value)}
/>
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
crossover={ActiveHighPassFilter}
title="HighPass"
setCrossover={commitHighPass}
onLiveValue={handleLiveHighPassValue}
/>
<CrossoverPanel
crossover={ActiveLowPassFilter}
title="LowPass"
setCrossover={commitLowPass}
onLiveValue={handleLiveLowPassValue}
/>
</ChannelGroup>
<ChannelGroup title="Actions">
<ChannelActions
bypass={ActivePeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={handleResetClick}
/>
</ChannelGroup>
</div>
</div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeChannelState.name} PEQ?</h3>
<p>
This will set all PEQ bands on {activeChannelState.name} back to flat (0 dB, Peak).
This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmResetPeq}>
Reset
</Button>
</div>
</div>
</div>
)}
</>
);
}
export default ChannelSection;