diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6a05c75..aa3630d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -338,4 +338,66 @@ pub async fn set_delay( .set_delay(channel, delay, unit) .map_err(|e| e.to_string()) }) +} + +#[tauri::command] +pub async fn set_matrix_routing( + state: State<'_, AppState>, + id: u64, + output: types::OutputChannel, + inputs: Vec, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_matrix_routing(output, inputs) + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn set_matrix_gain( + state: State<'_, AppState>, + id: u64, + input_channel: types::InputChannel, + output_channel: types::OutputChannel, + gain: f32, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_matrix_gain(input_channel, output_channel, gain) + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn set_geq_band( + state: State<'_, AppState>, + id: u64, + input_channel: types::InputChannel, + frequency_band: types::DiscreteFrequency, + gain: f32, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_geq_band(input_channel, frequency_band, gain) + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn set_geq_bypass( + state: State<'_, AppState>, + id: u64, + input_channel: types::InputChannel, + bypass: bool, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_geq_bypass(input_channel, bypass) + .map_err(|e| e.to_string()) + }) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6094b4..ecca4ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,10 @@ pub fn run() { commands::set_limiter, commands::set_delay_unit, commands::set_delay, + commands::set_matrix_routing, + commands::set_matrix_gain, + commands::set_geq_band, + commands::set_geq_bypass, ] ) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b4a903..ff4ac39 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,7 +15,7 @@ "title": "thomann-dsp-app", "width": 1200, "height": 800, - "minWidth": 1000, + "minWidth": 1200, "minHeight": 600 } ], diff --git a/src/components/dsp408/CompressorSection/CompressorSection.tsx b/src/components/dsp408/CompressorSection/CompressorSection.tsx index 4dc8aaf..c3a6885 100644 --- a/src/components/dsp408/CompressorSection/CompressorSection.tsx +++ b/src/components/dsp408/CompressorSection/CompressorSection.tsx @@ -1,5 +1,5 @@ // CompressorSection.tsx -import VerticalSlider from '../Slider/VerticalSlider'; +import VerticalSlider from '../VerticalSlider/VerticalSlider'; import { OutputChannel, Ratio } from '../../../types/dsp408State'; import { DSP } from '../../../types/types'; import Button from '../../Button/Button'; diff --git a/src/components/dsp408/DelaySection/DelaySection.tsx b/src/components/dsp408/DelaySection/DelaySection.tsx index b9f3898..3b6258a 100644 --- a/src/components/dsp408/DelaySection/DelaySection.tsx +++ b/src/components/dsp408/DelaySection/DelaySection.tsx @@ -1,4 +1,4 @@ -import VerticalSlider from '../Slider/VerticalSlider'; +import VerticalSlider from '../VerticalSlider/VerticalSlider'; import { InputChannel, Channel, OutputChannel, DelayUnit } from '../../../types/dsp408State'; import { DSP } from '../../../types/types'; import Button from '../../Button/Button'; diff --git a/src/components/dsp408/GainSection/GainSection.tsx b/src/components/dsp408/GainSection/GainSection.tsx index 90d4dc8..7fef529 100644 --- a/src/components/dsp408/GainSection/GainSection.tsx +++ b/src/components/dsp408/GainSection/GainSection.tsx @@ -1,4 +1,4 @@ -import VerticalSlider from '../Slider/VerticalSlider'; +import VerticalSlider from '../VerticalSlider/VerticalSlider'; import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State'; import { DSP } from '../../../types/types'; import Button from '../../Button/Button'; diff --git a/src/components/dsp408/GateSection/GateSection.tsx b/src/components/dsp408/GateSection/GateSection.tsx index 10ec692..cc10120 100644 --- a/src/components/dsp408/GateSection/GateSection.tsx +++ b/src/components/dsp408/GateSection/GateSection.tsx @@ -1,5 +1,5 @@ // GateSection.tsx -import VerticalSlider from '../Slider/VerticalSlider'; +import VerticalSlider from '../VerticalSlider/VerticalSlider'; import { InputChannel } from '../../../types/dsp408State'; import { DSP } from '../../../types/types'; import ChannelGroup from '../ChannelGroup/ChannelGroup'; diff --git a/src/components/dsp408/GeqSection/GeqSection.css b/src/components/dsp408/GeqSection/GeqSection.css new file mode 100644 index 0000000..2ac883e --- /dev/null +++ b/src/components/dsp408/GeqSection/GeqSection.css @@ -0,0 +1,143 @@ +.geq-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); +} + +.geq-panels { + height: 100%; + display: flex; + gap: var(--space-2); + overflow-x: auto; +} + +.geq-band-panel { + position: relative; + height: 100%; + aspect-ratio: 4.2; + flex: 0 0 auto; + + min-width: 0; + + display: flex; + flex-direction: row; + align-items: stretch; + + /* padding: 2cqh 1.5cqh; */ + + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-md); + box-sizing: border-box; + gap: 0cqh; + background: var(--bg-void); + padding-bottom: 2cqh; + padding-top: 2cqh; +} + +.geq-band-cell { + flex: 1 1 0; + min-width: 2cqh; + min-height: 0; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 0.6cqh; +} + +.geq-band-cell__label { + flex: 0 0 auto; + font-family: var(--font-ui); + font-size: 3.2cqh; + color: var(--text-muted); + white-space: nowrap; +} + +.geq-channel-selector { + height: 100%; + width: 20cqh; + + display: flex; + flex-direction: column; + gap: 5cqh; + justify-content: center; + + box-sizing: border-box; +} + +.geq-side-panels { + height: 100%; + + display: flex; + flex-direction: column; + + gap: 2cqh; +} + +.geq-side-panels > :first-child { + flex: 0 0 60%; +} + +.geq-side-panels > :last-child { + flex: 1 1 auto; + min-height: 0; +} + +.geq-linear-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); +} + +.geq-graph-panel { + flex: 1; + min-width: 0; + min-height: 0; + + display: flex; + flex-direction: column; + + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-md); +} + +.geq-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); +} + +.geq-graph-panel > .container { + flex: 1; + min-height: 0; +} diff --git a/src/components/dsp408/GeqSection/GeqSection.tsx b/src/components/dsp408/GeqSection/GeqSection.tsx new file mode 100644 index 0000000..c19caed --- /dev/null +++ b/src/components/dsp408/GeqSection/GeqSection.tsx @@ -0,0 +1,346 @@ +import VerticalSlider from '../VerticalSlider/VerticalSlider'; +import { InputChannel, DiscreteFrequency } from '../../../types/dsp408State'; +import { DSP } from '../../../types/types'; +import Button from '../../Button/Button'; +import ChannelGroup from '../ChannelGroup/ChannelGroup'; +import { LinearGraph } from '../LinearGraph/LinearGraph'; +import { useMemo, useState } from 'react'; +import './GeqSection.css'; + +const GEQ_BANDS = [ + { frequency: DiscreteFrequency.F20, label: '20' }, + { frequency: DiscreteFrequency.F25, label: '25' }, + { frequency: DiscreteFrequency.F31_5, label: '31.5' }, + { frequency: DiscreteFrequency.F40, label: '40' }, + { frequency: DiscreteFrequency.F50, label: '50' }, + { frequency: DiscreteFrequency.F63, label: '63' }, + { frequency: DiscreteFrequency.F80, label: '80' }, + { frequency: DiscreteFrequency.F100, label: '100' }, + { frequency: DiscreteFrequency.F125, label: '125' }, + { frequency: DiscreteFrequency.F160, label: '160' }, + { frequency: DiscreteFrequency.F200, label: '200' }, + { frequency: DiscreteFrequency.F250, label: '250' }, + { frequency: DiscreteFrequency.F315, label: '315' }, + { frequency: DiscreteFrequency.F400, label: '400' }, + { frequency: DiscreteFrequency.F500, label: '500' }, + { frequency: DiscreteFrequency.F630, label: '630' }, + { frequency: DiscreteFrequency.F800, label: '800' }, + { frequency: DiscreteFrequency.F1000, label: '1k' }, + { frequency: DiscreteFrequency.F1250, label: '1.25k' }, + { frequency: DiscreteFrequency.F1600, label: '1.6k' }, + { frequency: DiscreteFrequency.F2000, label: '2k' }, + { frequency: DiscreteFrequency.F2500, label: '2.5k' }, + { frequency: DiscreteFrequency.F3150, label: '3.15k' }, + { frequency: DiscreteFrequency.F4000, label: '4k' }, + { frequency: DiscreteFrequency.F5000, label: '5k' }, + { frequency: DiscreteFrequency.F6300, label: '6.3k' }, + { frequency: DiscreteFrequency.F8000, label: '8k' }, + { frequency: DiscreteFrequency.F10000, label: '10k' }, + { frequency: DiscreteFrequency.F12500, label: '12.5k' }, + { frequency: DiscreteFrequency.F16000, label: '16k' }, + { frequency: DiscreteFrequency.F20000, label: '20k' }, +] as const; + +const GEQ_GAIN_MIN = -12; +const GEQ_GAIN_MAX = 12; +const GEQ_GAIN_STEP = 0.1; + +const INPUT_ORDER: InputChannel[] = [ + InputChannel.InA, + InputChannel.InB, + InputChannel.InC, + InputChannel.InD, +]; + +type LiveBands = Partial>; + +type SetGeqBandFn = ( + input: InputChannel, + band: DiscreteFrequency, + gain: number +) => Promise; + +type SetGeqBypassFn = (input: InputChannel, bypass: boolean) => Promise; + +function GeqBandPanel({ + channel, + dsp, + setBand, + onLiveValue, +}: { + channel: InputChannel; + dsp: DSP; + setBand: SetGeqBandFn; + onLiveValue: (channel: InputChannel, band: DiscreteFrequency, value: number) => void; +}) { + const inputState = dsp.state.current_config.input_states[channel]; + const geq = inputState.geq; + + return ( +
+ {GEQ_BANDS.map(({ frequency, label }) => ( +
+ onLiveValue(channel, frequency, value)} + onChange={(value) => setBand(channel, frequency, value)} + style={{ width: '100%', flex: '1 1 auto' }} + /> +
{label}
+
+ ))} +
+ ); +} + +function GeqGraph({ + title, + channel, + dsp, + liveBands, +}: { + title: string; + channel: InputChannel; + dsp: DSP; + liveBands: LiveBands | undefined; +}) { + const geq = dsp.state.current_config.input_states[channel].geq; + + const labelByX = useMemo(() => { + const map = new Map(); + GEQ_BANDS.forEach((band, i) => map.set(i + 1, band.label)); + return map; + }, []); + + const values = GEQ_BANDS.map( + ({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0 + ); + + return ( +
+
{title}
+ labelByX.get(Math.round(v)) ?? ''} + formatY={(v) => v.toFixed(0)} + draw={({ ctx, toPx, plot }) => { + const barWidth = (plot.width / GEQ_BANDS.length) * 0.6; + + values.forEach((gain, i) => { + const x = i + 1; + const [xPx, zeroY] = toPx(x, 0); + const [, gainY] = toPx(x, gain); + + ctx.fillStyle = gain >= 0 ? '#4ade80' : '#f87171'; + const top = Math.min(zeroY, gainY); + const height = Math.abs(gainY - zeroY); + ctx.fillRect(xPx - barWidth / 2, top, barWidth, height); + }); + }} + /> +
+ ); +} + +function GeqChannelSelector({ + channel, + inputs, + onSelect, +}: { + channel: InputChannel; + inputs: { channel: InputChannel; label: string }[]; + onSelect: (channel: InputChannel) => void; +}) { + return ( +
+ {inputs.map(({ channel: c, label }) => ( + + ))} +
+ ); +} + +function GeqActions({ + bypass, + resetting, + onBypass, + onReset, +}: { + bypass: boolean; + resetting: boolean; + onBypass: () => void; + onReset: () => void; +}) { + return ( +
+ + + +
+ ); +} + +function GeqSection({ + dsp, + setGeqBand, + setGeqBypass, +}: { + dsp: DSP; + setGeqBand: SetGeqBandFn; + setGeqBypass: SetGeqBypassFn; +}) { + const [activeChannel, setActiveChannel] = useState(INPUT_ORDER[0]); + const [liveGeq, setLiveGeq] = useState>>({}); + const [resetting, setResetting] = useState(false); // NEW + + const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => { + setLiveGeq((prev) => ({ + ...prev, + [channel]: { ...prev[channel], [band]: value }, + })); + }; + + const handleSetBand: SetGeqBandFn = async (channel, band, gain) => { + const success = await setGeqBand(channel, band, gain); + if (success) { + setLiveGeq((prev) => { + const channelLive = prev[channel]; + if (!channelLive || !(band in channelLive)) return prev; + const next = { ...channelLive }; + delete next[band]; + return { ...prev, [channel]: next }; + }); + } + return success; + }; + + // NEW: sequential per-band reset, since hardware has no bulk reset command. + const handleReset = async (channel: InputChannel) => { + if (resetting) return; + setResetting(true); + + for (const { frequency } of GEQ_BANDS) { + // Skip bands already at 0 to save a round trip. + const current = dsp.state.current_config.input_states[channel].geq.gains[frequency] ?? 0; + if (current === 0) continue; + + await handleSetBand(channel, frequency, 0); + } + + setResetting(false); + }; + + const activeInputState = dsp.state.current_config.input_states[activeChannel]; + const activeGeq = activeInputState.geq; + const activeLiveBands = liveGeq[activeChannel]; + + const handleBypassClick = () => { + setGeqBypass(activeChannel, !activeGeq.bypass); + }; + + const channelOptions = INPUT_ORDER.map((channel) => ({ + channel, + label: dsp.state.current_config.input_states[channel].name, + })); + + return ( + <> +
+ +
+ +
+
+ + + + +
+ + + + + + handleReset(activeChannel)} + /> + +
+
+
+ + ); +} + +export default GeqSection; diff --git a/src/components/dsp408/LimiterSection/LimiterSection.tsx b/src/components/dsp408/LimiterSection/LimiterSection.tsx index 2d51252..12bbabe 100644 --- a/src/components/dsp408/LimiterSection/LimiterSection.tsx +++ b/src/components/dsp408/LimiterSection/LimiterSection.tsx @@ -1,5 +1,5 @@ // LimiterSection.tsx -import VerticalSlider from '../Slider/VerticalSlider'; +import VerticalSlider from '../VerticalSlider/VerticalSlider'; import { OutputChannel } from '../../../types/dsp408State'; import { DSP } from '../../../types/types'; import ChannelGroup from '../ChannelGroup/ChannelGroup'; diff --git a/src/components/dsp408/LinearGraph/LinearGraph.tsx b/src/components/dsp408/LinearGraph/LinearGraph.tsx index 427cd25..e189fe5 100644 --- a/src/components/dsp408/LinearGraph/LinearGraph.tsx +++ b/src/components/dsp408/LinearGraph/LinearGraph.tsx @@ -113,7 +113,7 @@ export const LinearGraph = forwardRef(funct const bottomBase = cq.h * 4; const leftBase = cq.w * 2; const xLabelSpace = xLabel ? cq.h * 7 : 0; - const yLabelSpace = yLabel ? cq.w * 6 : 0; + const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0; return { top: padding?.top ?? topBase, diff --git a/src/components/dsp408/Matrixsection/Matrixsection.css b/src/components/dsp408/Matrixsection/Matrixsection.css new file mode 100644 index 0000000..1a400b6 --- /dev/null +++ b/src/components/dsp408/Matrixsection/Matrixsection.css @@ -0,0 +1,171 @@ +.matrix-section { + container-type: size; + position: absolute; + left: 0; + right: 0; + bottom: 0; + + height: var(--dsp-control-heigth); + + display: flex; + align-items: stretch; + justify-content: center; + + box-sizing: border-box; + + background: var(--bg-panel); + + z-index: 20; + + border-top: var(--border-width) solid var(--border-hairline); +} + +/* ---------- Placeholder graph area (top) ---------- */ +/* Intentionally minimal for now — see chat for ideas on what belongs here. */ + +.matrix-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); +} + +.matrix-graph-panel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + + display: flex; + align-items: center; + justify-content: center; + + border: var(--border-width) dashed var(--border-hairline); + border-radius: var(--radius-md); + + font-family: var(--font-ui); + font-size: var(--font-xs); + color: var(--text-muted); +} + +/* ---------- Matrix grid (bottom) ---------- */ + +.matrix-grid-panel { + flex: 1 1 auto; + min-width: 0; + + display: flex; + flex-direction: column; + + padding: var(--space-2); + box-sizing: border-box; + + overflow: auto; +} + +.matrix-grid { + display: grid; + /* 1 corner cell + 1 column per input */ + grid-template-columns: minmax(8ch, 12cqh) repeat(var(--matrix-input-count, 4), minmax(9cqh, 1fr)); + /* 1 header row + 1 row per output */ + grid-auto-rows: minmax(6cqh, 1fr); + + gap: var(--border-width); + background: var(--border-hairline); + + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-md); + overflow: hidden; +} + +.matrix-grid__corner, +.matrix-grid__col-header, +.matrix-grid__row-header { + background: var(--bg-void); + font-family: var(--font-ui); + font-weight: var(--font-weight-semibold); + color: var(--text-muted); + + display: flex; + align-items: center; + justify-content: center; + text-align: center; + + font-size: 2.5cqh; + padding: 0.5cqh; + + min-width: 0; + min-height: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.matrix-grid__row-header { + justify-content: flex-start; + padding-left: 1cqh; +} + +.matrix-cell { + position: relative; + + background: var(--bg-panel); + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5cqh; + + min-width: 0; + min-height: 0; + + border: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + touch-action: none; +} + +.matrix-cell:focus-visible { + outline: 2px solid var(--accent-brand); + outline-offset: -2px; +} + +.matrix-cell--off { + color: var(--text-muted); +} + +.matrix-cell--off .matrix-cell__dot { + width: 0.8cqh; + height: 0.8cqh; + border-radius: 50%; + background: currentColor; + opacity: 0.35; +} + +.matrix-cell--on { + background: color-mix(in srgb, var(--accent-brand) 14%, var(--bg-panel)); + color: var(--accent-brand); +} + +.matrix-cell--dragging { + background: color-mix(in srgb, var(--accent-brand) 26%, var(--bg-panel)); +} + +.matrix-cell__gain { + font-size: 2.6cqh; + font-weight: var(--font-weight-semibold); + font-family: var(--font-ui); + white-space: nowrap; +} + +.matrix-cell__link-icon { + width: 2.2cqh; + height: 2.2cqh; +} diff --git a/src/components/dsp408/Matrixsection/Matrixsection.tsx b/src/components/dsp408/Matrixsection/Matrixsection.tsx new file mode 100644 index 0000000..61373c0 --- /dev/null +++ b/src/components/dsp408/Matrixsection/Matrixsection.tsx @@ -0,0 +1,257 @@ +// MatrixSection.tsx +import { Fragment, useRef, useState } from 'react'; +import { InputChannel, OutputChannel } from '../../../types/dsp408State'; +import { DSP } from '../../../types/types'; +import './MatrixSection.css'; + +// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in +// dsp408State, e.g. In1..In4. Adjust the import/labels below if the real +// enum differs. +const INPUTS: InputChannel[] = [ + InputChannel.InA, + InputChannel.InB, + InputChannel.InC, + InputChannel.InD, +]; + +const OUTPUTS: OutputChannel[] = [ + OutputChannel.Out1, + OutputChannel.Out2, + OutputChannel.Out3, + OutputChannel.Out4, + OutputChannel.Out5, + OutputChannel.Out6, + OutputChannel.Out7, + OutputChannel.Out8, +]; + +const INPUT_LABEL: Record = { + [InputChannel.InA]: 'In 1', + [InputChannel.InB]: 'In 2', + [InputChannel.InC]: 'In 3', + [InputChannel.InD]: 'In 4', +}; + +const GAIN_MIN = -60; +const GAIN_MAX = 12; +const GAIN_DEFAULT = 0; // gain a link is set to the moment it's first routed +const GAIN_STEP = 0.5; // wheel / arrow-key nudge +const DRAG_PX_PER_DB = 4; // vertical drag sensitivity + +function clampGain(v: number) { + return Math.min(GAIN_MAX, Math.max(GAIN_MIN, v)); +} + +function formatGain(v: number) { + return `${v > 0 ? '+' : ''}${v.toFixed(1)}`; +} + +// ASSUMPTION: each output tracks which inputs are currently routed to it +// (output_states[out].matrix_inputs: InputChannel[]) and the last-known gain +// per input (output_states[out].matrix_gains: Partial>, in dB, kept around even if the link is later removed so it can +// be restored). Adjust these two accessors if the real shape differs — +// everything else only depends on the two functions below. +type MatrixOutputState = { + matrix_inputs?: InputChannel[]; + matrix_gains?: Partial>; +}; + +function isRouted(dsp: DSP, output: OutputChannel, input: InputChannel): boolean { + const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState; + return state.matrix_inputs?.includes(input) ?? false; +} + +function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number { + const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState; + return state.matrix_gains?.[input] ?? GAIN_DEFAULT; +} + +function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] { + const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState; + return state.matrix_inputs ?? []; +} + +function MatrixCell({ + output, + input, + connected, + gain, + onToggle, + onGainChange, +}: { + output: OutputChannel; + input: InputChannel; + connected: boolean; + gain: number; + onToggle: () => void; + onGainChange: (gain: number) => void; +}) { + const [liveGain, setLiveGain] = useState(null); + const [dragging, setDragging] = useState(false); + const dragStart = useRef<{ y: number; base: number } | null>(null); + + const displayGain = liveGain ?? gain; + + const handlePointerDown = (e: React.PointerEvent) => { + if (!connected) return; // plain click on an off cell just routes it (see onClick) + e.currentTarget.setPointerCapture(e.pointerId); + dragStart.current = { y: e.clientY, base: gain }; + setDragging(true); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragStart.current) return; + const deltaPx = dragStart.current.y - e.clientY; // up = louder + const next = clampGain(dragStart.current.base + deltaPx / DRAG_PX_PER_DB); + setLiveGain(next); + }; + + const endDrag = () => { + if (dragStart.current && liveGain !== null) { + onGainChange(liveGain); + } + dragStart.current = null; + setDragging(false); + setLiveGain(null); + }; + + const handleWheel = (e: React.WheelEvent) => { + if (!connected) return; + e.preventDefault(); + onGainChange(clampGain(gain + (e.deltaY < 0 ? GAIN_STEP : -GAIN_STEP))); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } else if (connected && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + const dir = e.key === 'ArrowUp' ? 1 : -1; + onGainChange(clampGain(gain + dir * GAIN_STEP)); + } else if (connected && (e.key === 'Backspace' || e.key === 'Delete')) { + e.preventDefault(); + onToggle(); + } + }; + + return ( +
+ {connected ? ( + {formatGain(displayGain)} + ) : ( + + )} +
+ ); +} + +function MatrixSection({ + dsp, + setMatrixRouting, + setMatrixGain, +}: { + dsp: DSP; + setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise; + setMatrixGain: ( + inputChannel: InputChannel, + outputChannel: OutputChannel, + gain: number + ) => Promise; +}) { + // Toggling a cell sends the *whole* next input list for that output — + // setMatrixRouting replaces, it doesn't add/remove a single link. + const handleToggle = (output: OutputChannel, input: InputChannel) => { + const current = getRoutedInputs(dsp, output); + const nowConnecting = !current.includes(input); + const next = nowConnecting ? [...current, input] : current.filter((i) => i !== input); + + void setMatrixRouting(output, next).then((ok) => { + // Give a freshly-connected link a defined starting gain rather than + // leaving it at whatever stale value the backend last had for it. + if (ok && nowConnecting) { + void setMatrixGain(input, output, GAIN_DEFAULT); + } + }); + }; + + const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => { + void setMatrixGain(input, output, gain); + }; + + return ( + <> +
+
Routing overview — see chat for ideas
+
+ +
+
+
+
+ {INPUTS.map((input) => ( +
+ {INPUT_LABEL[input]} +
+ ))} + + {OUTPUTS.map((output) => ( + +
+ {dsp.state.current_config.output_states[output].name} +
+ {INPUTS.map((input) => { + const connected = isRouted(dsp, output, input); + return ( + handleToggle(output, input)} + onGainChange={(gain) => handleGainChange(output, input, gain)} + /> + ); + })} +
+ ))} +
+
+
+ + ); +} + +export default MatrixSection; diff --git a/src/components/dsp408/Slider/VerticalSlider.css b/src/components/dsp408/VerticalSlider/VerticalSlider.css similarity index 93% rename from src/components/dsp408/Slider/VerticalSlider.css rename to src/components/dsp408/VerticalSlider/VerticalSlider.css index a3e70cf..a8839e3 100644 --- a/src/components/dsp408/Slider/VerticalSlider.css +++ b/src/components/dsp408/VerticalSlider/VerticalSlider.css @@ -26,6 +26,11 @@ padding-top: 1.2cqh; } +.gauge-track.no-ticks { + padding-left: calc(2.75cqh); + padding-right: calc(2.75cqh); +} + .ticks { flex: 0 0 auto; @@ -114,6 +119,11 @@ overflow: hidden; } +.value.no-ticks { + padding-left: 0; + padding-right: 0; +} + .value input { width: auto; min-width: 0; diff --git a/src/components/dsp408/Slider/VerticalSlider.tsx b/src/components/dsp408/VerticalSlider/VerticalSlider.tsx similarity index 90% rename from src/components/dsp408/Slider/VerticalSlider.tsx rename to src/components/dsp408/VerticalSlider/VerticalSlider.tsx index fcf278c..bf9b35c 100644 --- a/src/components/dsp408/Slider/VerticalSlider.tsx +++ b/src/components/dsp408/VerticalSlider/VerticalSlider.tsx @@ -16,6 +16,8 @@ type VerticalSliderProps = { unit?: string; + showTicks?: boolean; + className?: string; style?: React.CSSProperties; }; @@ -39,6 +41,7 @@ export default function VerticalSlider({ onChange, onInput, unit = '', + showTicks = true, className, style, }: VerticalSliderProps) { @@ -221,12 +224,17 @@ export default function VerticalSlider({ return (
-
-
- {Array.from({ length: tickCount }).map((_, i) => ( - - ))} -
+
+ {showTicks && ( +
+ {Array.from({ length: tickCount }).map((_, i) => ( + + ))} +
+ )}
-
- {Array.from({ length: tickCount }).map((_, i) => ( - - ))} -
+ {showTicks && ( +
+ {Array.from({ length: tickCount }).map((_, i) => ( + + ))} +
+ )}
-
+
- {unit} + {unit && {unit}}
); diff --git a/src/hooks/useDSP408.ts b/src/hooks/useDSP408.ts index 3a1ab7c..6ea9815 100644 --- a/src/hooks/useDSP408.ts +++ b/src/hooks/useDSP408.ts @@ -14,19 +14,9 @@ import { DelayUnit, Meters, DSPState, + DiscreteFrequency, } from '../types/dsp408State'; -// import { -// createDSP408, -// removeDSP408, -// connectDSP408, -// disconnectDSP408, -// getDSP408State, -// recallPresetDSP408, -// DSP408SetCurrentPresetName, -// getDSP408MeterLevels, -// } from '../api/dsp408'; - type NotifyFunction = (message: string, type: 'success' | 'error', dspName?: string) => void; type SetDsps = React.Dispatch>; @@ -68,6 +58,18 @@ export type DSP408Device = { ) => Promise; setDelayUnit: (unit: DelayUnit) => Promise; setDelay: (channel: Channel, delay: number) => Promise; + setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise; + setMatrixGain: ( + inputChannel: InputChannel, + outputChannel: OutputChannel, + gain: number + ) => Promise; + setGeqBand: ( + inputChannel: InputChannel, + frequencyBand: DiscreteFrequency, + gain: number + ) => Promise; + setGeqBypass: (inputChannel: InputChannel, bypass: boolean) => Promise; }; // Polling @@ -894,6 +896,237 @@ export function useDSP408( }); } + // Matrix Routing + async function setMatrixRouting( + dsp: DSP, + output: OutputChannel, + inputs: InputChannel[] + ): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_matrix_routing', { + id: dsp.id, + output, + inputs, + }); + + if (!success) { + notify('Failed to change matrix routing', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + output_states: { + ...currentConfig.output_states, + [output]: { + ...currentConfig.output_states[output], + matrix_routes: { + ...currentConfig.output_states[output].matrix_routes, + connected: inputs, + }, + }, + }, + }, + }, + }; + }); + + notify('Matrix routing updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`Matrix routing update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + + // Matrix Gain + async function setMatrixGain( + dsp: DSP, + inputChannel: InputChannel, + outputChannel: OutputChannel, + gain: number + ): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_matrix_gain', { + id: dsp.id, + inputChannel, + outputChannel, + gain, + }); + + if (!success) { + notify('Failed to change matrix gain', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + + const outputState = currentConfig.output_states[outputChannel]; + + const inputIndex = Object.values(InputChannel).indexOf(inputChannel); + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + output_states: { + ...currentConfig.output_states, + [outputChannel]: { + ...outputState, + matrix_routes: { + ...outputState.matrix_routes, + gains: outputState.matrix_routes.gains.map((g, i) => + i === inputIndex ? gain : g + ), + }, + }, + }, + }, + }, + }; + }); + + notify('Matrix gain updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`Matrix gain update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + + // GEQ Band + async function setGeqBand( + dsp: DSP, + inputChannel: InputChannel, + frequencyBand: DiscreteFrequency, + gain: number + ): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_geq_band', { + id: dsp.id, + inputChannel, + frequencyBand, + gain, + }); + + if (!success) { + notify('Failed to change GEQ band', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + const inputState = currentConfig.input_states[inputChannel]; + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + input_states: { + ...currentConfig.input_states, + [inputChannel]: { + ...inputState, + geq: { + ...inputState.geq, + gains: { + ...inputState.geq.gains, + [frequencyBand]: gain, + }, + }, + }, + }, + }, + }, + }; + }); + + notify('GEQ band updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`GEQ band update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + + // GEQ Bypass + async function setGeqBypass( + dsp: DSP, + inputChannel: InputChannel, + bypass: boolean + ): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_geq_bypass', { + id: dsp.id, + inputChannel, + bypass, + }); + + if (!success) { + notify('Failed to change GEQ bypass', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + const inputState = currentConfig.input_states[inputChannel]; + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + input_states: { + ...currentConfig.input_states, + [inputChannel]: { + ...inputState, + geq: { + ...inputState.geq, + bypass, + }, + }, + }, + }, + }, + }; + }); + + notify(bypass ? 'GEQ bypass enabled' : 'GEQ bypass disabled', 'success', dsp.name); + return true; + } catch (err) { + notify(`GEQ bypass update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + // Return API function forDSP(dsp: DSP): DSP408Device { return { @@ -928,6 +1161,14 @@ export function useDSP408( ) => setLimiter(dsp, outputChannel, threshold, attack, release), setDelayUnit: (unit: DelayUnit) => setDelayUnit(dsp, unit), setDelay: (channel: Channel, delay: number) => setDelay(dsp, channel, delay), + setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => + setMatrixRouting(dsp, output, inputs), + setMatrixGain: (inputChannel: InputChannel, outputChannel: OutputChannel, gain: number) => + setMatrixGain(dsp, inputChannel, outputChannel, gain), + setGeqBand: (inputChannel: InputChannel, frequencyBand: DiscreteFrequency, gain: number) => + setGeqBand(dsp, inputChannel, frequencyBand, gain), + setGeqBypass: (inputChannel: InputChannel, bypass: boolean) => + setGeqBypass(dsp, inputChannel, bypass), }; } diff --git a/src/pages/DspPage/DspPage.tsx b/src/pages/DspPage/DspPage.tsx index ce8e42f..5d0309d 100644 --- a/src/pages/DspPage/DspPage.tsx +++ b/src/pages/DspPage/DspPage.tsx @@ -12,6 +12,8 @@ import LimiterSection from '../../components/dsp408/LimiterSection/LimiterSectio import DelaySection from '../../components/dsp408/DelaySection/DelaySection'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; import { InputChannel, OutputChannel } from '../../types/dsp408State'; +import MatrixSection from '../../components/dsp408/Matrixsection/Matrixsection'; +import GeqSection from '../../components/dsp408/GeqSection/GeqSection'; function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) { const [activeSidebar, setActiveSidebar] = useState('overview'); @@ -198,6 +200,20 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) { /> ); + case 'matrix': + return ( + + ); + + case 'geq': + return ( + + ); + default: return null; } diff --git a/src/types/dsp408State.ts b/src/types/dsp408State.ts index 2a7f130..b196e42 100644 --- a/src/types/dsp408State.ts +++ b/src/types/dsp408State.ts @@ -71,6 +71,40 @@ export enum PEQFilter { AllPass2 = 'AllPass2', } +export enum DiscreteFrequency { + F20 = 'F20', + F25 = 'F25', + F31_5 = 'F31_5', + F40 = 'F40', + F50 = 'F50', + F63 = 'F63', + F80 = 'F80', + F100 = 'F100', + F125 = 'F125', + F160 = 'F160', + F200 = 'F200', + F250 = 'F250', + F315 = 'F315', + F400 = 'F400', + F500 = 'F500', + F630 = 'F630', + F800 = 'F800', + F1000 = 'F1000', + F1250 = 'F1250', + F1600 = 'F1600', + F2000 = 'F2000', + F2500 = 'F2500', + F3150 = 'F3150', + F4000 = 'F4000', + F5000 = 'F5000', + F6300 = 'F6300', + F8000 = 'F8000', + F10000 = 'F10000', + F12500 = 'F12500', + F16000 = 'F16000', + F20000 = 'F20000', +} + // ---------------- STATE ---------------- export type Gate = { @@ -95,7 +129,7 @@ export type Limiter = { }; export type GraphicEQ = { - gains: number[]; + gains: Record; bypass: boolean; }; @@ -212,7 +246,39 @@ const createDefaultGate = (): Gate => ({ }); const createDefaultGraphicEQ = (): GraphicEQ => ({ - gains: Array(31).fill(0), + gains: { + [DiscreteFrequency.F20]: 0, + [DiscreteFrequency.F25]: 0, + [DiscreteFrequency.F31_5]: 0, + [DiscreteFrequency.F40]: 0, + [DiscreteFrequency.F50]: 0, + [DiscreteFrequency.F63]: 0, + [DiscreteFrequency.F80]: 0, + [DiscreteFrequency.F100]: 0, + [DiscreteFrequency.F125]: 0, + [DiscreteFrequency.F160]: 0, + [DiscreteFrequency.F200]: 0, + [DiscreteFrequency.F250]: 0, + [DiscreteFrequency.F315]: 0, + [DiscreteFrequency.F400]: 0, + [DiscreteFrequency.F500]: 0, + [DiscreteFrequency.F630]: 0, + [DiscreteFrequency.F800]: 0, + [DiscreteFrequency.F1000]: 0, + [DiscreteFrequency.F1250]: 0, + [DiscreteFrequency.F1600]: 0, + [DiscreteFrequency.F2000]: 0, + [DiscreteFrequency.F2500]: 0, + [DiscreteFrequency.F3150]: 0, + [DiscreteFrequency.F4000]: 0, + [DiscreteFrequency.F5000]: 0, + [DiscreteFrequency.F6300]: 0, + [DiscreteFrequency.F8000]: 0, + [DiscreteFrequency.F10000]: 0, + [DiscreteFrequency.F12500]: 0, + [DiscreteFrequency.F16000]: 0, + [DiscreteFrequency.F20000]: 0, + }, bypass: false, });