merge
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
/* InputSection.css */
|
||||
.channel-graph-section {
|
||||
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1cqh;
|
||||
padding: 0.5cqh;
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.channel-graph-panel {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.channel-bypass-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.channel-bypass-overlay span {
|
||||
font-size: 8cqh;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
color: #f87171;
|
||||
text-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(-8deg);
|
||||
opacity: 0.85;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.channel-graph__title {
|
||||
flex: 0 0 auto;
|
||||
|
||||
text-align: center;
|
||||
padding: 1cqh;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.5cqh;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-graph-panel > .container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Bottom control bar — same pattern as .compressor-section: absolute,
|
||||
bottom-anchored, panels flow in a row and scroll horizontally instead
|
||||
of squeezing into a fixed max-width. */
|
||||
.channel-control-section {
|
||||
container-type: size;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: var(--dsp-control-heigth);
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
background: var(--bg-panel);
|
||||
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.channel-control-panel {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: 2cqh;
|
||||
}
|
||||
|
||||
/* Peq Sliders */
|
||||
.channel-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
aspect-ratio: 0.8;
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.channel-panel__title {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-top: 2cqh;
|
||||
padding-bottom: 2cqh;
|
||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.channel-panel__grid {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
gap: 2cqh;
|
||||
|
||||
padding: 2cqh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.channel-panel__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: 1cqh;
|
||||
}
|
||||
|
||||
.channel-panel__cell-label {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 3.2cqh;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crossover-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
aspect-ratio: 0.3;
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
gap: 2cqh;
|
||||
background: var(--bg-void);
|
||||
padding-bottom: 2cqh;
|
||||
}
|
||||
|
||||
.crossover-panel__title {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-top: 2cqh;
|
||||
padding-bottom: 2cqh;
|
||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Now the root of a ChoiceSelect rather than a native <select>. */
|
||||
.crossover-panel__select {
|
||||
flex: 0 0 auto;
|
||||
width: 85%;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.crossover-panel__select .choice-select__input,
|
||||
.crossover-panel__select .choice-select__input option {
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
/* padding-left: 1cqh; */
|
||||
}
|
||||
|
||||
.crossover-panel__select {
|
||||
flex: 0 0 auto;
|
||||
width: 85%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
}
|
||||
|
||||
.crossover-panel__select .choice-select__input,
|
||||
.crossover-panel__select .choice-select__input option {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
padding-left: 2cqh;
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.peq-band-list {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5cqh;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-void);
|
||||
padding: 1cqh;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.peq-band-list__row {
|
||||
flex: 1 1 auto;
|
||||
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 0.4fr 0.55fr 0.4fr 0.5fr 1.2fr 0.5fr;
|
||||
align-items: stretch;
|
||||
gap: 1cqh;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.peq-band-list__row--header {
|
||||
flex: 0 0 auto;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
padding-top: 1cqh;
|
||||
padding-bottom: 1cqh;
|
||||
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.peq-band-list__row--active {
|
||||
background: var(--bg-panel);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.peq-band-list__cell {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.peq-band-list__input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.2cqh;
|
||||
padding: 0.5cqh;
|
||||
|
||||
text-align: center;
|
||||
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.peq-band-list__stepper,
|
||||
.peq-band-list__select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
}
|
||||
|
||||
.channel-graph-panel__magnitude-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.channel-graph__title-row {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1cqh 1.5cqh;
|
||||
}
|
||||
|
||||
.channel-graph__title-row .channel-graph__title {
|
||||
padding: 0; /* was on the standalone title element; row now owns spacing */
|
||||
}
|
||||
|
||||
.channel-graph-view-toggle {
|
||||
display: flex;
|
||||
gap: 0.4cqh;
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3cqh;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.channel-graph-view-toggle__btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2cqh;
|
||||
padding: 0.6cqh 1.2cqh;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-graph-view-toggle__btn.active {
|
||||
background: var(--accent-brand);
|
||||
color: var(--bg-void);
|
||||
}
|
||||
|
||||
.peq-band-list__stepper .choice-stepper__input,
|
||||
.peq-band-list__select .choice-select__input {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
|
||||
background: transparent;
|
||||
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
// 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,
|
||||
} 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';
|
||||
|
||||
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 commit = (overrides: Partial<PEQ>) => {
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(next.gain, next.frequency, next.q, 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={peq.q}
|
||||
onChange={(value) => commit({ q: value })}
|
||||
onInput={(value) => onLiveValue({ ...peq, q: value })}
|
||||
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 })}
|
||||
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 peq = liveBands[band] ?? bands[band];
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(band, next.gain, next.frequency, 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);
|
||||
|
||||
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={peq.q}
|
||||
values={qValues}
|
||||
formatLabel={(v) => v.toFixed(2)}
|
||||
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`}
|
||||
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) => commit(band, { filter_type: value, q: 3.0 })}
|
||||
/>
|
||||
|
||||
<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, current.q, 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;
|
||||
Reference in New Issue
Block a user