From 470b95ace3e97d35251a8577f6dd38d4a45498ff Mon Sep 17 00:00:00 2001 From: LucasDLTG Date: Thu, 6 Aug 2026 20:51:38 +0200 Subject: [PATCH] feat: add delay tab --- src-tauri/src/commands.rs | 31 ++ src-tauri/src/lib.rs | 2 + src/components/Button/Button.css | 11 + .../CompressorSection/CompressorSection.css | 1 - .../CompressorSection/CompressorSection.tsx | 1 - .../dsp408/DelaySection/DelaySection.css | 117 ++++++ .../dsp408/DelaySection/DelaySection.tsx | 368 ++++++++++++++++++ .../dsp408/Slider/VerticalSlider.tsx | 6 +- src/hooks/useDSP408.ts | 161 +++++--- src/pages/DspPage/DspPage.tsx | 11 + 10 files changed, 662 insertions(+), 47 deletions(-) create mode 100644 src/components/dsp408/DelaySection/DelaySection.css create mode 100644 src/components/dsp408/DelaySection/DelaySection.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4538d1c..6a05c75 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,3 +1,4 @@ +use dsp_thomann::dsp408::types::DelayUnit; use tauri::State; use crate::state::{AppState, DSPDevice}; use dsp_thomann::dsp408::{DSP408, types}; @@ -307,4 +308,34 @@ pub async fn set_limiter( .set_limiter(channel, threshold, attack, release) .map_err(|e| e.to_string()) }) +} + +#[tauri::command] +pub async fn set_delay_unit( + state: State<'_, AppState>, + id: u64, + unit: types::DelayUnit, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_delay_unit(unit) + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn set_delay( + state: State<'_, AppState>, + id: u64, + channel: types::Channel, + delay: f32, + unit: DelayUnit +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_delay(channel, delay, unit) + .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 cabe86c..c6094b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,8 @@ pub fn run() { commands::set_gate, commands::set_compressor, commands::set_limiter, + commands::set_delay_unit, + commands::set_delay, ] ) .run(tauri::generate_context!()) diff --git a/src/components/Button/Button.css b/src/components/Button/Button.css index c5c35e7..da21d40 100644 --- a/src/components/Button/Button.css +++ b/src/components/Button/Button.css @@ -45,6 +45,12 @@ } /* Active toggle states */ +.btn-toggle.active-info { + background: var(--accent-brand); + border-color: var(--accent-brand); + color: var(--bg-void); +} + .btn-toggle.active-danger { background: var(--accent-down); border-color: var(--accent-down); @@ -58,6 +64,11 @@ } /* Optional: active state hover */ +.btn-toggle.active-info:hover:not(:disabled) { + background: var(--accent-brand); + border-color: var(--accent-brand); +} + .btn-toggle.active-danger:hover:not(:disabled) { background: var(--accent-down); border-color: var(--accent-down); diff --git a/src/components/dsp408/CompressorSection/CompressorSection.css b/src/components/dsp408/CompressorSection/CompressorSection.css index 5d36e1d..c9a5051 100644 --- a/src/components/dsp408/CompressorSection/CompressorSection.css +++ b/src/components/dsp408/CompressorSection/CompressorSection.css @@ -29,7 +29,6 @@ .compressor-panel { position: relative; - height: 10; aspect-ratio: 0.5; flex: 0 0 auto; diff --git a/src/components/dsp408/CompressorSection/CompressorSection.tsx b/src/components/dsp408/CompressorSection/CompressorSection.tsx index 3b2093a..4dc8aaf 100644 --- a/src/components/dsp408/CompressorSection/CompressorSection.tsx +++ b/src/components/dsp408/CompressorSection/CompressorSection.tsx @@ -200,7 +200,6 @@ function CompressorPanel({ onLiveValue: (channel: OutputChannel, key: CompressorParamKey, value: number | Ratio) => void; }) { const outputState = dsp.state.current_config.output_states[channel]; - // ASSUMPTION: compressor settings live at output_states[channel].compressor const comp = outputState.compressor as CompressorValues; const commit = (overrides: Partial) => { diff --git a/src/components/dsp408/DelaySection/DelaySection.css b/src/components/dsp408/DelaySection/DelaySection.css new file mode 100644 index 0000000..5f36692 --- /dev/null +++ b/src/components/dsp408/DelaySection/DelaySection.css @@ -0,0 +1,117 @@ +.delay-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); +} + +.delay-panels { + height: 100%; + display: flex; + gap: var(--space-2); + overflow-x: auto; +} + +.delay-panel { + position: relative; + height: 100%; + aspect-ratio: 1 / 4; + 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; +} + +.delay-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; +} + +.delay-unit-selector { + height: 100%; + width: 20cqh; + + display: flex; + flex-direction: column; + gap: 5cqh; + justify-content: center; + + box-sizing: border-box; +} + +.delay-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); +} + +.delay-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); +} + +.delay-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); +} + +.delay-graph-panel > .container { + flex: 1; + min-height: 0; +} diff --git a/src/components/dsp408/DelaySection/DelaySection.tsx b/src/components/dsp408/DelaySection/DelaySection.tsx new file mode 100644 index 0000000..b9f3898 --- /dev/null +++ b/src/components/dsp408/DelaySection/DelaySection.tsx @@ -0,0 +1,368 @@ +import VerticalSlider from '../Slider/VerticalSlider'; +import { InputChannel, Channel, OutputChannel, DelayUnit } 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 './DelaySection.css'; + +const DELAY_RANGES: Record = { + [DelayUnit.Millisecond]: [0, 680.0], + [DelayUnit.Meter]: [0, 233.58], + [DelayUnit.Feet]: [0, 766.329], +}; + +const UNIT_LABEL: Record = { + [DelayUnit.Millisecond]: 'ms', + [DelayUnit.Meter]: 'm', + [DelayUnit.Feet]: 'ft', +}; + +const UNIT_ORDER: DelayUnit[] = [DelayUnit.Millisecond, DelayUnit.Meter, DelayUnit.Feet]; + +const DELAY_STEPS = 255; +const DELAY_STEP: Record = { + [DelayUnit.Millisecond]: (680.0 - 0) / (DELAY_STEPS - 1), + [DelayUnit.Meter]: (233.58 - 0) / (DELAY_STEPS - 1), + [DelayUnit.Feet]: (766.329 - 0) / (DELAY_STEPS - 1), +}; + +type SetDelayFn = (channel: Channel, delay: number) => Promise; + +function channelKey(channel: Channel): string { + return 'Input' in channel ? `Input:${channel.Input}` : `Output:${channel.Output}`; +} + +function DelayPanel({ + channel, + dsp, + setDelay, + onLiveValue, +}: { + channel: Channel; + dsp: DSP; + setDelay: SetDelayFn; + onLiveValue: (channel: Channel, value: number) => void; +}) { + function getChannelState(channel: Channel) { + if ('Input' in channel) { + return dsp.state.current_config.input_states[channel.Input]; + } else { + return dsp.state.current_config.output_states[channel.Output]; + } + } + + const state = getChannelState(channel); + const unit = dsp.state.current_config.delay_unit; + const [min, max] = DELAY_RANGES[unit]; + + return ( +
+
{state.name}
+ + onLiveValue(channel, value)} + onChange={(value) => setDelay(channel, value)} + /> +
+ ); +} + +function DelayGraph({ + title, + rows, + unit, +}: { + title: string; + rows: { label: string; delay: number }[]; + unit: DelayUnit; +}) { + const [min, max] = DELAY_RANGES[unit]; + const labelByX = useMemo(() => { + const map = new Map(); + rows.forEach((row, i) => map.set(i + 1, row.label)); + return map; + }, [rows]); + + return ( +
+
{title}
+ labelByX.get(Math.round(v)) ?? ''} + formatY={(v) => v.toFixed(0)} + draw={({ ctx, toPx, plot }) => { + const barWidth = (plot.width / rows.length) * 0.55; + + rows.forEach((row, i) => { + const x = i + 1; + const [xPx, baseY] = toPx(x, min); + const [, topY] = toPx(x, row.delay); + + ctx.fillStyle = '#4ade80'; + ctx.fillRect(xPx - barWidth / 2, topY, barWidth, baseY - topY); + }); + }} + /> +
+ ); +} + +function DelayUnitSelector({ + unit, + onSelect, +}: { + unit: DelayUnit; + onSelect: (unit: DelayUnit) => void; +}) { + return ( +
+ {UNIT_ORDER.map((u) => ( + + ))} +
+ ); +} + +function DelaySection({ + dsp, + setDelay, + setDelayUnit, + setDSPState, +}: { + dsp: DSP; + setDelay: SetDelayFn; + setDelayUnit: (unit: DelayUnit) => Promise; + setDSPState: (updater: (dsp: DSP) => DSP) => void; +}) { + const unit = dsp.state?.current_config.delay_unit ?? DelayUnit.Millisecond; + + const inputs: InputChannel[] = [ + InputChannel.InA, + InputChannel.InB, + InputChannel.InC, + InputChannel.InD, + ]; + + const outputs: { channel: Channel; title: string }[] = [ + { channel: { Output: OutputChannel.Out1 }, title: 'Out 1' }, + { channel: { Output: OutputChannel.Out2 }, title: 'Out 2' }, + { channel: { Output: OutputChannel.Out3 }, title: 'Out 3' }, + { channel: { Output: OutputChannel.Out4 }, title: 'Out 4' }, + { channel: { Output: OutputChannel.Out5 }, title: 'Out 5' }, + { channel: { Output: OutputChannel.Out6 }, title: 'Out 6' }, + { channel: { Output: OutputChannel.Out7 }, title: 'Out 7' }, + { channel: { Output: OutputChannel.Out8 }, title: 'Out 8' }, + ]; + + const [liveDelay, setLiveDelay] = useState>>({}); + + const handleLiveValue = (channel: Channel, value: number) => { + setLiveDelay((prev) => ({ + ...prev, + [channelKey(channel)]: value, + })); + }; + + const handleSetDelay: SetDelayFn = async (channel, delay) => { + const success = await setDelay(channel, delay); + + if (success) { + const key = channelKey(channel); + + setLiveDelay((prev) => { + if (!(key in prev)) return prev; + + const next = { ...prev }; + delete next[key]; + return next; + }); + } + + return success; + }; + + const committedDelay = (channel: Channel): number => + 'Input' in channel + ? dsp.state.current_config.input_states[channel.Input].delay + : dsp.state.current_config.output_states[channel.Output].delay; + + const getDelay = (channel: Channel): number => { + const key = channelKey(channel); + + if (key in liveDelay) { + return liveDelay[key]!; + } + + return committedDelay(channel); + }; + + function convertDelayUnit(value: number, from: DelayUnit, to: DelayUnit): number { + const [, fromMax] = DELAY_RANGES[from]; + const [, toMax] = DELAY_RANGES[to]; + + if (fromMax === 0) return 0; + + const ratio = value / fromMax; + + return ratio * toMax; + } + + const handleUnitChange = async (newUnit: DelayUnit) => { + if (newUnit === unit) return; + + const success = await setDelayUnit(newUnit); + + if (!success) return; + + setDSPState((d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + delay_unit: newUnit, + + input_states: { + ...currentConfig.input_states, + [InputChannel.InA]: { + ...currentConfig.input_states[InputChannel.InA], + delay: convertDelayUnit( + currentConfig.input_states[InputChannel.InA].delay, + unit, + newUnit + ), + }, + [InputChannel.InB]: { + ...currentConfig.input_states[InputChannel.InB], + delay: convertDelayUnit( + currentConfig.input_states[InputChannel.InB].delay, + unit, + newUnit + ), + }, + [InputChannel.InC]: { + ...currentConfig.input_states[InputChannel.InC], + delay: convertDelayUnit( + currentConfig.input_states[InputChannel.InC].delay, + unit, + newUnit + ), + }, + [InputChannel.InD]: { + ...currentConfig.input_states[InputChannel.InD], + delay: convertDelayUnit( + currentConfig.input_states[InputChannel.InD].delay, + unit, + newUnit + ), + }, + }, + + output_states: { + ...currentConfig.output_states, + ...Object.fromEntries( + Object.values(OutputChannel).map((channel) => [ + channel, + { + ...currentConfig.output_states[channel], + delay: convertDelayUnit( + currentConfig.output_states[channel].delay, + unit, + newUnit + ), + }, + ]) + ), + }, + }, + }, + }; + }); + }; + + const inputRows = inputs.map((channel) => ({ + label: dsp.state.current_config.input_states[channel].name, + delay: getDelay({ Input: channel }), + })); + + const outputRows = outputs.map(({ channel, title }) => ({ + label: title, + delay: getDelay(channel), + })); + + return ( + <> +
+ + +
+ +
+
+ + {inputs.map((channel) => ( + + ))} + + + + {outputs.map(({ channel, title }) => ( + + ))} + + + + + +
+
+ + ); +} + +export default DelaySection; diff --git a/src/components/dsp408/Slider/VerticalSlider.tsx b/src/components/dsp408/Slider/VerticalSlider.tsx index 7a01e27..fcf278c 100644 --- a/src/components/dsp408/Slider/VerticalSlider.tsx +++ b/src/components/dsp408/Slider/VerticalSlider.tsx @@ -91,7 +91,11 @@ export default function VerticalSlider({ } const decimals = useMemo( - () => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0), + () => + Math.min( + 3, + Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0) + ), [step, switchStep] ); diff --git a/src/hooks/useDSP408.ts b/src/hooks/useDSP408.ts index cb44596..4328b8a 100644 --- a/src/hooks/useDSP408.ts +++ b/src/hooks/useDSP408.ts @@ -11,6 +11,7 @@ import { defaultMeters, OutputChannel, Ratio, + DelayUnit, } from '../types/dsp408State'; import { @@ -28,15 +29,13 @@ type NotifyFunction = (message: string, type: 'success' | 'error', dspName?: str type SetDsps = React.Dispatch>; -// ============================================================ // Helpers -// ============================================================ - function updateDSP(setDsps: SetDsps, dspId: number, updater: (dsp: DSP) => DSP) { setDsps((prev) => prev.map((dsp) => (dsp.id === dspId ? updater(dsp) : dsp))); } export type DSP408Device = { + setDSPState: (updater: (dsp: DSP) => DSP) => void; connect: () => Promise; disconnect: () => Promise; recallPreset: (index: number) => Promise; @@ -65,12 +64,11 @@ export type DSP408Device = { attack: number, release: number ) => Promise; + setDelayUnit: (unit: DelayUnit) => Promise; + setDelay: (channel: Channel, delay: number) => Promise; }; -// ============================================================ // Polling -// ============================================================ - function useDSPPolling( dsps: DSP[], selected: number | null, @@ -165,10 +163,6 @@ function useDSPPolling( }, [setDsps]); } -// ============================================================ -// Main hook -// ============================================================ - export function useDSP408( dsps: DSP[], setDsps: SetDsps, @@ -177,10 +171,7 @@ export function useDSP408( setPage: React.Dispatch>, notify: NotifyFunction ) { - // ---------------------------------------------------------- // Polling control - // ---------------------------------------------------------- - const setPollingPaused = (id: number, paused: boolean) => { updateDSP(setDsps, id, (dsp) => ({ ...dsp, @@ -188,13 +179,14 @@ export function useDSP408( })); }; + function setDSPState(dsp: DSP, updater: (dsp: DSP) => DSP) { + updateDSP(setDsps, dsp.id, updater); + } + // Polling is part of the hook. useDSPPolling(dsps, selected, setDsps); - // ---------------------------------------------------------- // Generic operation wrapper - // ---------------------------------------------------------- - async function withPollingPaused(dsp: DSP, operation: () => Promise): Promise { setPollingPaused(dsp.id, true); @@ -205,10 +197,7 @@ export function useDSP408( } } - // ========================================================== // Connection - // ========================================================== - async function connectDSP(dsp: DSP) { setPollingPaused(dsp.id, true); @@ -261,10 +250,7 @@ export function useDSP408( } } - // ========================================================== // Add / Remove - // ========================================================== - async function addDSP(dsp: Omit) { try { const id = await createDSP408(dsp.ip, dsp.port, dsp.deviceId); @@ -312,10 +298,7 @@ export function useDSP408( } } - // ========================================================== // Presets - // ========================================================== - async function recallPreset(dsp: DSP, presetIndex: number) { return withPollingPaused(dsp, async () => { try { @@ -398,10 +381,7 @@ export function useDSP408( }); } - // ========================================================== // Gain - // ========================================================== - async function setGain(dsp: DSP, channel: Channel, gain: number): Promise { return withPollingPaused(dsp, async () => { try { @@ -435,10 +415,7 @@ export function useDSP408( }); } - // ========================================================== // Mute - // ========================================================== - async function setMute(dsp: DSP, channel: Channel, muted: boolean): Promise { return withPollingPaused(dsp, async () => { try { @@ -511,10 +488,7 @@ export function useDSP408( }); } - // ========================================================== // Inverse - // ========================================================== - async function setInverse(dsp: DSP, channel: Channel, inverted: boolean): Promise { return withPollingPaused(dsp, async () => { try { @@ -587,9 +561,7 @@ export function useDSP408( }); } - // ========================================================== // Gate - // ========================================================== async function setGate( dsp: DSP, inputChannel: InputChannel, @@ -641,9 +613,7 @@ export function useDSP408( }); } - // ========================================================== // Compressor - // ========================================================== async function setCompressor( dsp: DSP, outputChannel: OutputChannel, @@ -708,9 +678,7 @@ export function useDSP408( }); } - // ========================================================== // Limiter - // ========================================================== async function setLimiter( dsp: DSP, outputChannel: OutputChannel, @@ -769,12 +737,115 @@ export function useDSP408( }); } - // ========================================================== - // Return API - // ========================================================== + // Delay Unit + async function setDelayUnit(dsp: DSP, unit: DelayUnit): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_delay_unit', { + id: dsp.id, + unit, + }); + if (!success) { + notify('Failed to change delay unit', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + return { + ...d, + state: { + ...d.state, + current_config: { + ...d.state.current_config, + delay_unit: unit, + }, + }, + }; + }); + + notify('Delay unit updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`Delay unit update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + + // Channel Delay + async function setDelay(dsp: DSP, channel: Channel, delay: number): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_delay', { + id: dsp.id, + channel, + delay, + unit: dsp.state.current_config.delay_unit, + }); + + if (!success) { + notify('Failed to change channel delay', 'error', dsp.name); + return false; + } + + updateDSP(setDsps, dsp.id, (d) => { + if (!d.state) return d; + + const currentConfig = d.state.current_config; + + if ('Input' in channel) { + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + input_states: { + ...currentConfig.input_states, + [channel.Input]: { + ...currentConfig.input_states[channel.Input], + delay, + }, + }, + }, + }, + }; + } + + return { + ...d, + state: { + ...d.state, + current_config: { + ...currentConfig, + output_states: { + ...currentConfig.output_states, + [channel.Output]: { + ...currentConfig.output_states[channel.Output], + delay, + }, + }, + }, + }, + }; + }); + + notify('Channel delay updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`Channel delay update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + + // Return API function forDSP(dsp: DSP): DSP408Device { return { + setDSPState: (updater: (dsp: DSP) => DSP) => setDSPState(dsp, updater), connect: () => connectDSP(dsp), disconnect: () => disconnectDSP(dsp), recallPreset: (index: number) => recallPreset(dsp, index), @@ -803,6 +874,8 @@ export function useDSP408( attack: number, release: number ) => setLimiter(dsp, outputChannel, threshold, attack, release), + setDelayUnit: (unit: DelayUnit) => setDelayUnit(dsp, unit), + setDelay: (channel: Channel, delay: number) => setDelay(dsp, channel, delay), }; } @@ -818,6 +891,6 @@ export function useDSP408( recallPreset, setCurrentPresetName, - setGain, + setDSPState, }; } diff --git a/src/pages/DspPage/DspPage.tsx b/src/pages/DspPage/DspPage.tsx index 6f32df8..ce8e42f 100644 --- a/src/pages/DspPage/DspPage.tsx +++ b/src/pages/DspPage/DspPage.tsx @@ -9,6 +9,7 @@ import { DSP408Device } from '../../hooks/useDSP408'; import './DspPage.css'; import GateSection from '../../components/dsp408/GateSection/GateSection'; import LimiterSection from '../../components/dsp408/LimiterSection/LimiterSection'; +import DelaySection from '../../components/dsp408/DelaySection/DelaySection'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; import { InputChannel, OutputChannel } from '../../types/dsp408State'; @@ -187,6 +188,16 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) { case 'limiter': return ; + case 'delay': + return ( + + ); + default: return null; }