From a28dd890dd6a3f6ad759e824c35bf6c10044fee4 Mon Sep 17 00:00:00 2001 From: LucasDLTG Date: Wed, 12 Aug 2026 22:19:09 +0200 Subject: [PATCH] feat: start input section --- src/components/ChannelGroup/ChannelGroup.css | 2 +- .../VerticalSlider/VerticalSlider.tsx | 172 ++++++++++- .../components/DSP408Panel/DSP408Panel.tsx | 8 +- .../components/InputSection/InputSection.css | 105 ++++--- .../components/InputSection/InputSection.tsx | 269 ++++++++++-------- src/features/dsp408/types.ts | 22 +- 6 files changed, 395 insertions(+), 183 deletions(-) diff --git a/src/components/ChannelGroup/ChannelGroup.css b/src/components/ChannelGroup/ChannelGroup.css index f2de40e..0737a85 100644 --- a/src/components/ChannelGroup/ChannelGroup.css +++ b/src/components/ChannelGroup/ChannelGroup.css @@ -1,6 +1,6 @@ .channel-group { position: relative; - flex: 1 1 0; + /* flex: 1 1 0; */ min-height: 0; display: flex; diff --git a/src/components/VerticalSlider/VerticalSlider.tsx b/src/components/VerticalSlider/VerticalSlider.tsx index 001f756..3518e8f 100644 --- a/src/components/VerticalSlider/VerticalSlider.tsx +++ b/src/components/VerticalSlider/VerticalSlider.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import './VerticalSlider.css'; type StepFn = (value: number) => number; +type QuantizeFn = (value: number) => number; +type Scale = 'linear' | 'log'; type VerticalSliderProps = { min: number; @@ -9,6 +11,17 @@ type VerticalSliderProps = { step?: StepFn; + /** + * Optional hard snap function applied after every value change (drag, + * keyboard, wheel, or typed input) — takes precedence over the built-in + * step-based rounding. Use this when the underlying hardware/DSP only + * accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw + * index mapped through a log curve) and the slider must always land on + * one of those exact values rather than an interpolated approximation. + * See makeLogQuantize(). + */ + quantize?: QuantizeFn; + value: number; onChange?: (value: number) => Promise; onInput?: (value: number) => void; @@ -18,6 +31,9 @@ type VerticalSliderProps = { showTicks?: boolean; + /** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */ + scale?: Scale; + className?: string; style?: React.CSSProperties; }; @@ -37,16 +53,86 @@ function getDecimals(n: number) { return i === -1 ? 0 : s.length - i - 1; } +// --- scale helpers ----------------------------------------------------- + +/** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */ +function safeLogBound(n: number) { + return n > 0 ? n : Number.EPSILON; +} + +/** Maps a real value to a 0..1 position, honoring the chosen scale. */ +function valueToPercent(v: number, min: number, max: number, scale: Scale) { + if (scale === 'log') { + const logMin = Math.log(safeLogBound(min)); + const logMax = Math.log(safeLogBound(max)); + if (logMax === logMin) return 0; + const logV = Math.log(safeLogBound(v)); + return (logV - logMin) / (logMax - logMin); + } + if (max === min) return 0; + return (v - min) / (max - min); +} + +/** Maps a 0..1 position back to a real value, honoring the chosen scale. */ +function percentToValue(percent: number, min: number, max: number, scale: Scale) { + if (scale === 'log') { + const logMin = Math.log(safeLogBound(min)); + const logMax = Math.log(safeLogBound(max)); + return Math.exp(logMin + percent * (logMax - logMin)); + } + return min + percent * (max - min); +} + +/** + * Builds a StepFn that yields roughly `points` evenly-spaced steps between + * `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300) + * gives ~300 points across a 20 Hz - 20 kHz range, with step size growing + * proportionally to the current value (fine resolution at low frequencies, + * coarser at high ones — matches how the ear perceives frequency). + * + * This is an approximation used to size keyboard/wheel nudges — it does NOT + * guarantee landing on the same exact grid a real device would produce. + * Pair it with `quantize` (see makeLogQuantize) when you need the slider to + * always land exactly on the device's own value set. + */ +export function makeLogStep(min: number, max: number, points: number): StepFn { + const ratio = Math.pow(max / min, 1 / Math.max(1, points)); + const stepSize = ratio - 1; + return (value: number) => Math.max(min, value) * stepSize; +} + +/** + * Builds a QuantizeFn that snaps to the exact same log-spaced grid a device + * using a `raw` 0..points index would produce, e.g. for a frequency raw + * value stored as: + * raw = round(log(Hz / min) / log(max / min) * points) + * Hz = min * (max / min) ** (raw / points) + * makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set + * (raw 0..300), so the slider only ever settles on values the device itself + * would encode/decode to — no drift from an approximate step size. + */ +export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn { + const logRatio = Math.log(max / min); + return (value: number) => { + const v = Math.max(min, Math.min(max, value)); + const raw = Math.round((Math.log(v / min) / logRatio) * points); + const clampedRaw = Math.min(points, Math.max(0, raw)); + return min * Math.pow(max / min, clampedRaw / points); + }; +} + export default function VerticalSlider({ min, max, step = defaultStep, + quantize, value, onChange, onInput, unit = '', formatValue, showTicks = true, + scale = 'linear', className, style, }: VerticalSliderProps) { @@ -60,10 +146,25 @@ export default function VerticalSlider({ const dragging = useRef(false); const thumbRef = useRef(null); const keyboardEditing = useRef(false); + const wheelValue = useRef(null); + const wheelEditing = useRef(false); + const wheelCommitTimeout = useRef | null>(null); const [tickCount, setTickCount] = useState(MIN_TICKS); + if (scale === 'log' && (min <= 0 || max <= 0)) { + // eslint-disable-next-line no-console + console.warn( + 'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.' + ); + } + useEffect(() => { - if (dragging.current || keyboardEditing.current || editingText !== null) { + if ( + dragging.current || + keyboardEditing.current || + wheelEditing.current || + editingText !== null + ) { return; } setDisplayValue(value); @@ -99,6 +200,11 @@ export default function VerticalSlider({ function normalizeValue(input: number) { const clamped = Math.min(max, Math.max(min, input)); + + if (quantize) { + return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6)); + } + const currentStep = step(clamped); const snapped = min + Math.round((clamped - min) / currentStep) * currentStep; return Number(Math.min(max, Math.max(min, snapped)).toFixed(6)); @@ -119,7 +225,11 @@ export default function VerticalSlider({ keyboardEditing.current = true; e.preventDefault(); - const base = keyboardValue.current ?? value; + // Base off displayValue (our own last-known-good local state), not the + // `value` prop: if onChange's round trip through the parent/audio engine + // hasn't landed yet, `value` can still be stale, which made repeated + // key presses appear to snap back to the pre-press position. + const base = keyboardValue.current ?? displayValue; const currentStep = step(base); let next = base; @@ -147,11 +257,65 @@ export default function VerticalSlider({ } } + function handleWheel(e: WheelEvent) { + e.preventDefault(); + + wheelEditing.current = true; + + const base = wheelValue.current ?? displayValue; + const currentStep = step(base); + // deltaY < 0 means scrolling up/away from the user — treat that as + // increasing the value, matching the vertical slider's "up = more". + const direction = e.deltaY < 0 ? 1 : -1; + + const next = normalizeValue(base + direction * currentStep); + wheelValue.current = next; + updateValue(next); + + if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current); + wheelCommitTimeout.current = setTimeout(() => { + void commitWheel(); + }, 250); + } + + async function commitWheel() { + if (wheelValue.current == null) return; + + const next = wheelValue.current; + const success = await onChange?.(next); + + wheelEditing.current = false; + wheelValue.current = null; + + if (success === false) { + setDisplayValue(value); + dragValue.current = value; + onInput?.(value); + } + } + + useEffect(() => { + const el = sliderRef.current; + if (!el) return; + + // React attaches onWheel passively by default, so preventDefault() + // wouldn't stop the page from scrolling underneath the slider — attach + // natively with { passive: false } instead. + el.addEventListener('wheel', handleWheel, { passive: false }); + return () => el.removeEventListener('wheel', handleWheel); + }); + + useEffect(() => { + return () => { + if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current); + }; + }, []); + function valueFromPointer(clientY: number) { if (!sliderRef.current) return; const rect = sliderRef.current.getBoundingClientRect(); const percent = 1 - (clientY - rect.top) / rect.height; - const raw = min + percent * (max - min); + const raw = percentToValue(percent, min, max, scale); updateValue(raw); } @@ -212,7 +376,7 @@ export default function VerticalSlider({ setEditingText(null); } - const percent = (displayValue - min) / (max - min); + const percent = valueToPercent(displayValue, min, max, scale); return (
diff --git a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx index 2c601ec..227adaa 100644 --- a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx +++ b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx @@ -139,12 +139,10 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) { ); diff --git a/src/features/dsp408/components/InputSection/InputSection.css b/src/features/dsp408/components/InputSection/InputSection.css index f5223ac..feaf6b4 100644 --- a/src/features/dsp408/components/InputSection/InputSection.css +++ b/src/features/dsp408/components/InputSection/InputSection.css @@ -69,6 +69,7 @@ display: flex; align-items: stretch; + justify-content: center; /* ← add this */ gap: var(--space-4); box-sizing: border-box; @@ -219,7 +220,7 @@ .input-panel__cell-label { flex: 0 0 auto; font-family: var(--font-ui); - font-size: 2.5cqh; + font-size: 3.2cqh; color: var(--text-muted); white-space: nowrap; } @@ -271,72 +272,92 @@ border-radius: var(--radius-sm); } -.gain-meter-panel { - container-type: size; - - position: relative; - height: 100%; - aspect-ratio: 1 / 2; - flex: 0 0 auto; +.peq-band-list { + flex: 1 1 auto; + min-width: 0; + min-height: 0; display: flex; flex-direction: column; - align-items: center; + 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; + + aspect-ratio: 2; } -.gain-meter-panel__title { +.peq-band-list__row { + flex: 1 1 0; + min-height: 0; + min-width: 0; + + display: grid; + grid-template-columns: 1fr 1.2fr 1fr 1fr 1.4fr 1.2fr; + align-items: stretch; + gap: 1cqh; + + box-sizing: border-box; +} + +.peq-band-list__row--header { flex: 0 0 auto; - width: 100%; - text-align: center; + font-family: var(--font-ui); font-size: 4cqh; + color: var(--text-primary); + text-align: center; font-weight: var(--font-weight-semibold); - padding-top: 2cqh; - padding-bottom: 2cqh; + border-bottom: var(--border-width) solid var(--border-hairline); + padding-top: 1cqh; + padding-bottom: 1cqh; + min-width: 0; overflow: hidden; text-overflow: ellipsis; } -.gain-meter-panel__grid { - flex: 1 1 auto; - min-height: 0; - width: 100%; - padding: 2cqh; - box-sizing: border-box; - - display: grid; - grid-template-columns: 1fr 1fr; - /* slider/meter row gets the bulk of the height, button row stays compact */ - grid-template-rows: 1fr auto; - gap: 1.5cqh; +.peq-band-list__row--active { + background: var(--bg-panel); + border-radius: var(--radius-sm); } -.gain-meter-panel__cell { +.peq-band-list__cell { min-width: 0; min-height: 0; + display: flex; - align-items: center; - justify-content: center; -} - -.gain-meter-panel__cell--slider, -.gain-meter-panel__cell--meter { - height: 100%; -} - -.gain-meter-panel__cell--btn { - flex: 0 0 auto; -} - -.gain-meter-panel__cell--meter { align-items: stretch; - justify-content: stretch; + justify-content: center; + + text-align: center; +} + +.peq-band-list__input, +.peq-band-list__select { + 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; } diff --git a/src/features/dsp408/components/InputSection/InputSection.tsx b/src/features/dsp408/components/InputSection/InputSection.tsx index baa37fa..05c6b63 100644 --- a/src/features/dsp408/components/InputSection/InputSection.tsx +++ b/src/features/dsp408/components/InputSection/InputSection.tsx @@ -1,5 +1,8 @@ // InputSection.tsx -import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider'; +import VerticalSlider, { + makeLogStep, + makeLogQuantize, +} from '../../../../components/VerticalSlider/VerticalSlider'; // ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping // input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values // if the actual enum differs. @@ -13,9 +16,23 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import Button from '../../../../components/Button/Button'; import MeterBar from '../../../../components/MeterBar/MeterBar'; -const FREQ_MIN = 20; -const FREQ_MAX = 20000; -const FREQ_STEP = 1; +const PEQ_BANDS: PEQBand[] = [ + PEQBand.B1, + PEQBand.B2, + PEQBand.B3, + PEQBand.B4, + PEQBand.B5, + PEQBand.B6, + PEQBand.B7, + PEQBand.B8, +]; + +// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types +const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [ + { value: PEQFilter.Peak, label: 'Peak' }, + { value: PEQFilter.LowShelf, label: 'Low Shelf' }, + { value: PEQFilter.HighShelf, label: 'High Shelf' }, +]; const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [ { value: CrossoverFilter.Bw6, label: 'BW-6' }, @@ -112,6 +129,12 @@ function PeqPanelSliders({ ); } +const FREQ_MIN = 19.7; +const FREQ_MAX = 20160; +const FREQ_POINTS = 300; +const FREQ_STEP = makeLogStep(FREQ_MIN, FREQ_MAX, FREQ_POINTS); +const FREQ_QUANTIZE = makeLogQuantize(FREQ_MIN, FREQ_MAX, FREQ_POINTS); + function CrossoverPanel({ channel, dsp, @@ -124,8 +147,7 @@ function CrossoverPanel({ setCrossover: ( channel: Channel, frequency: number, - filter_type: CrossoverFilter, - bypass: boolean + filter_type: CrossoverFilter ) => Promise; }) { const channelState = @@ -145,7 +167,7 @@ function CrossoverPanel({ bypass?: boolean; }) => { const next = { frequency, filter_type, bypass, ...overrides }; - return setCrossover(channel, next.frequency, next.filter_type, next.bypass); + return setCrossover(channel, next.frequency, next.filter_type); }; return ( @@ -153,9 +175,11 @@ function CrossoverPanel({
{title}
1} + min={FREQ_MIN} + max={FREQ_MAX} + scale="log" + step={FREQ_STEP} + quantize={FREQ_QUANTIZE} unit=" Hz" value={frequency} onChange={(value) => commit({ frequency: value })} @@ -194,99 +218,117 @@ function CrossoverPanel({ ); } -function GainMeterPanel({ - dsp, +function PeqBandList({ channel, - title, - meter, - setGain, - setMute, - setInverse, + dsp, + activeBand, + setActiveBand, + setPEQ, }: { + channel: InputChannel; dsp: DSP; - channel: Channel; - title: string; - meter: Meter; - setGain: (channel: Channel, gain: number) => Promise; - setMute: (channel: Channel, muted: boolean) => Promise; - setInverse: (channel: Channel, inverted: boolean) => Promise; + activeBand: PEQBand; + setActiveBand: (band: PEQBand) => void; + setPEQ: SetPEQFn; }) { - const channelState = - 'Input' in channel - ? dsp.state?.current_config.input_states[channel.Input] - : dsp.state?.current_config.output_states[channel.Output]; + const inputState = dsp.state.current_config.input_states[channel]; - const muted = channelState?.mute ?? false; - const inverted = channelState?.phase_inverted ?? false; - const gain = channelState?.gain ?? 0; - - const toggleMute = async () => { - await setMute(channel, !muted); - }; - - const toggleInverse = async () => { - await setInverse(channel, !inverted); + const commit = (band: PEQBand, overrides: Partial) => { + const peq = inputState.peq_chain.bands[band] as PEQ; + const next = { ...peq, ...overrides }; + return setPEQ( + { Input: channel }, + band, + next.gain, + next.frequency, + next.q, + next.filter_type, + next.bypass + ); }; return ( -
-
{title}
- -
-
- (v >= -20 ? 0.1 : 0.5)} - unit=" dB" - value={gain} - onChange={(value) => setGain(channel, value)} - style={{ width: '100%', height: '100%' }} - /> -
- -
- -
- -
- -
- -
- -
+
+
+
Band
+
Freq
+
Q
+
Gain
+
Type
+
Bypass
+ + {PEQ_BANDS.map((band, i) => { + const peq = inputState.peq_chain.bands[band] as PEQ; + const isActive = band === activeBand; + + return ( +
+ + + commit(band, { frequency: Number(e.target.value) })} + /> + + commit(band, { q: Number(e.target.value) })} + /> + + commit(band, { gain: Number(e.target.value) })} + /> + + + + +
+ ); + })}
); } @@ -310,23 +352,17 @@ type setPeqBandFn = ( function InputSection({ dsp, channel, - set_high_pass: setHighPass, - set_low_pass, + setHighPass, + setLowPass, setPeq, - // setPeqBypass, - setGain, - setMute, - setInverse, + setPeqBypass, }: { dsp: DSP; channel: InputChannel; - set_high_pass: SetFilterFn; - set_low_pass: SetFilterFn; + setHighPass: SetFilterFn; + setLowPass: SetFilterFn; setPeq: setPeqBandFn; - // setPeqBypass; - setGain: (channel: Channel, gain: number) => Promise; - setMute: (channel: Channel, muted: boolean) => Promise; - setInverse: (channel: Channel, inverted: boolean) => Promise; + setPeqBypass: (channel: Channel, bypass: boolean) => Promise; }) { const [activeBand, setActiveBand] = useState(PEQBand.B1); @@ -408,7 +444,14 @@ function InputSection({
- + + @@ -425,18 +468,6 @@ function InputSection({ setCrossover={setHighPass} /> - - - - {/*
({ const createDefaultPEQChain = (): PEQChain => ({ bands: Object.fromEntries( - Object.values(PEQBand) - .filter((band): band is PEQBand => typeof band === 'number') - .map((band) => [band, createDefaultPEQ()]) + Object.values(PEQBand).map((band) => [band, createDefaultPEQ()]) ) as Record, bypass: false, });