feat: add delay tab

This commit is contained in:
2026-08-06 20:51:38 +02:00
parent e802067056
commit 470b95ace3
10 changed files with 662 additions and 47 deletions
+31
View File
@@ -1,3 +1,4 @@
use dsp_thomann::dsp408::types::DelayUnit;
use tauri::State;
use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::{DSP408, types};
@@ -308,3 +309,33 @@ pub async fn set_limiter(
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay_unit(
state: State<'_, AppState>,
id: u64,
unit: types::DelayUnit,
) -> Result<bool, String> {
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<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
+2
View File
@@ -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!())
+11
View File
@@ -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);
@@ -29,7 +29,6 @@
.compressor-panel {
position: relative;
height: 10;
aspect-ratio: 0.5;
flex: 0 0 auto;
@@ -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<CompressorValues>) => {
@@ -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;
}
@@ -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, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
const UNIT_LABEL: Record<DelayUnit, string> = {
[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, number> = {
[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<boolean>;
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 (
<div className="delay-panel">
<div className="delay-panel__title">{state.name}</div>
<VerticalSlider
min={min}
max={max}
step={DELAY_STEP[unit]}
unit={' ' + UNIT_LABEL[unit]}
value={state.delay}
onInput={(value) => onLiveValue(channel, value)}
onChange={(value) => setDelay(channel, value)}
/>
</div>
);
}
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<number, string>();
rows.forEach((row, i) => map.set(i + 1, row.label));
return map;
}, [rows]);
return (
<div className="delay-graph-panel">
<div className="delay-graph-title">{title}</div>
<LinearGraph
xMin={0.5}
xMax={rows.length + 0.5}
xStep={1}
yMin={min}
yMax={max}
xLabel="Channel"
yLabel={UNIT_LABEL[unit]}
showGrid={true}
formatX={(v) => 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);
});
}}
/>
</div>
);
}
function DelayUnitSelector({
unit,
onSelect,
}: {
unit: DelayUnit;
onSelect: (unit: DelayUnit) => void;
}) {
return (
<div className="delay-unit-selector">
{UNIT_ORDER.map((u) => (
<Button
key={u}
variant="toggle"
className={u === unit ? 'active-info' : ''}
onClick={() => onSelect(u)}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{UNIT_LABEL[u]}
</Button>
))}
</div>
);
}
function DelaySection({
dsp,
setDelay,
setDelayUnit,
setDSPState,
}: {
dsp: DSP;
setDelay: SetDelayFn;
setDelayUnit: (unit: DelayUnit) => Promise<boolean>;
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<Partial<Record<string, number>>>({});
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 (
<>
<div className="delay-linear-graph-section">
<DelayGraph title="Input Delay" rows={inputRows} unit={unit} />
<DelayGraph title="Output Delay" rows={outputRows} unit={unit} />
</div>
<div className="delay-section">
<div className="delay-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
<DelayPanel
key={channel}
channel={{ Input: channel }}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<DelayPanel
key={title}
channel={channel}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Unit">
<DelayUnitSelector unit={unit} onSelect={handleUnitChange} />
</ChannelGroup>
</div>
</div>
</>
);
}
export default DelaySection;
@@ -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]
);
+117 -44
View File
@@ -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<React.SetStateAction<DSP[]>>;
// ============================================================
// 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<void>;
disconnect: () => Promise<void>;
recallPreset: (index: number) => Promise<boolean>;
@@ -65,12 +64,11 @@ export type DSP408Device = {
attack: number,
release: number
) => Promise<boolean>;
setDelayUnit: (unit: DelayUnit) => Promise<boolean>;
setDelay: (channel: Channel, delay: number) => Promise<boolean>;
};
// ============================================================
// 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<React.SetStateAction<AppPage>>,
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<T>(dsp: DSP, operation: () => Promise<T>): Promise<T> {
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<DSP, 'id' | 'status' | 'state' | 'meters' | 'pollingPaused'>) {
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<boolean> {
return withPollingPaused(dsp, async () => {
try {
@@ -435,10 +415,7 @@ export function useDSP408(
});
}
// ==========================================================
// Mute
// ==========================================================
async function setMute(dsp: DSP, channel: Channel, muted: boolean): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
@@ -511,10 +488,7 @@ export function useDSP408(
});
}
// ==========================================================
// Inverse
// ==========================================================
async function setInverse(dsp: DSP, channel: Channel, inverted: boolean): Promise<boolean> {
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<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('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<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('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,
};
}
+11
View File
@@ -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 <LimiterSection dsp={dsp} set_limiter={dsp408.setLimiter} />;
case 'delay':
return (
<DelaySection
dsp={dsp}
setDelay={dsp408.setDelay}
setDelayUnit={dsp408.setDelayUnit}
setDSPState={dsp408.setDSPState}
/>
);
default:
return null;
}