feat: gate sectioon

This commit is contained in:
2026-07-31 13:24:24 +02:00
parent 06710b68d4
commit daf2bf32aa
18 changed files with 717 additions and 68 deletions
+3
View File
@@ -5,3 +5,6 @@ This template should help get you started developing with Tauri, React and Types
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
+19 -1
View File
@@ -1,6 +1,6 @@
use tauri::State;
use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::DSP408;
use dsp_thomann::dsp408::{DSP408, types};
use std::{net::IpAddr, str::FromStr};
use std::sync::{Arc, Mutex};
@@ -247,3 +247,21 @@ pub async fn set_current_preset_name(
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
+1
View File
@@ -20,6 +20,7 @@ pub fn run() {
commands::recall_preset,
commands::get_meter_levels,
commands::set_current_preset_name,
commands::set_gate,
]
)
.run(tauri::generate_context!())
+18 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { DSPState, Meters } from '../types/dsp408State';
import { DSPState, Meters, InputChannel } from '../types/dsp408State';
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
invoke<number>('create_dsp408', {
@@ -29,3 +29,20 @@ export const DSP408SetCurrentPresetName = (id: number, name: string) =>
});
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });
export const DSP408SetGate = (
id: number,
channel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
) =>
invoke<boolean>('set_gate', {
id,
channel,
threshold,
attack,
hold,
release,
});
@@ -81,7 +81,12 @@ function GainPanel({
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
/>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute} style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}>
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
@@ -1,4 +1,127 @@
.rerere {
/* GateSection.css */
.gate-panel-viewport {
position: relative;
aspect-ratio: 160 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gate-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
padding-bottom: var(--space-2);
}
.gate-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
.gate-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: var(--space-2);
padding: var(--space-2);
box-sizing: border-box;
}
.gate-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: var(--space-1);
}
.gate-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: var(--font-xxs);
color: var(--text-muted);
white-space: nowrap;
}
/* Envelope graph */
.linear-graph-section {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.gate-graph__header {
display: flex;
align-items: baseline;
justify-content: space-between;
font-family: var(--font-ui);
padding: 0 var(--space-2);
}
.gate-graph__title {
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
}
.gate-graph__channel {
font-size: var(--font-xs);
color: var(--text-muted);
}
/* Container */
.gate-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);
}
.gate-panels {
height: 100%;
width: 10%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
+222 -16
View File
@@ -1,31 +1,237 @@
// GateSection.tsx
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { InputChannel, Channel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useMemo, useRef, useState } from 'react';
import './GateSection.css';
function GateSection({
const PANEL_DESIGN_WIDTH = 160;
const PANEL_DESIGN_HEIGHT = 400;
const GATE_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 0, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'hold', label: 'Hold', min: 10, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
type GateValues = {
threshold: number;
attack: number;
hold: number;
release: number;
};
type SetGateFn = (
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
) => Promise<boolean>;
function GatePanel({
channel,
dsp,
setGate,
onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
setGate: SetGateFn;
onLiveValue: (channel: InputChannel, key: GateParamKey, value: number) => void;
}) {
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const inputState = dsp.state.current_config.input_states[channel];
const gate = inputState.gate;
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="rerere">
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={0}
style={{ width: 70, height: 250, flex: '0 0 auto' }}
/>
<div className="gate-panel-viewport" ref={viewportRef}>
<div
className="gate-panel"
style={{
width: PANEL_DESIGN_WIDTH,
height: PANEL_DESIGN_HEIGHT,
transform: `translate(-50%, -50%) scale(${scale})`,
}}
>
<div className="gate-panel__title">{inputState.name}</div>
<div className="gate-panel__grid">
{GATE_PARAMS.map((param) => (
<div className="gate-panel__cell" key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={param.step}
unit={param.unit}
value={gate[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => {
return setGate(
channel,
param.key === 'threshold' ? value : gate.threshold,
param.key === 'attack' ? value : gate.attack,
param.key === 'hold' ? value : gate.hold,
param.key === 'release' ? value : gate.release
);
}}
style={{ width: '100%', flex: '1 1 auto' }}
maxAspectHeight={165}
/>
<div className="gate-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
</div>
);
}
function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const inputs = [InputChannel.InA, InputChannel.InB, InputChannel.InC, InputChannel.InD];
// Tracks whichever channel's sliders were most recently touched, so the
// shared envelope graph always reflects the gate the user is currently editing.
const [activeChannel, setActiveChannel] = useState<InputChannel>(inputs[0]);
// Live, uncommitted override for whichever channel(s) are mid-drag. Sliders
// report every intermediate value here via onInput (synchronous), separate
// from the async onChange that actually round-trips to the backend. This is
// what makes the graph track the drag instead of lagging behind the commit.
const [liveGate, setLiveGate] = useState<Partial<Record<InputChannel, GateValues>>>({});
const handleLiveValue = (channel: InputChannel, key: GateParamKey, value: number) => {
setActiveChannel(channel);
setLiveGate((prev) => {
const base = prev[channel] ?? dsp.state.current_config.input_states[channel].gate;
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetGate: SetGateFn = async (channel, threshold, attack, hold, release) => {
setActiveChannel(channel);
const success = await set_gate(channel, threshold, attack, hold, release);
if (success) {
// Backend confirmed — drop the local override and let dsp.state (the
// source of truth) take over again.
setLiveGate((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGate = liveGate[activeChannel] ?? activeInputState.gate;
// Layout the envelope in data space: closed -> attack -> hold (still open) ->
// release -> closed, with a little pre/post roll so the flat segments are visible.
const envelope = useMemo(() => {
const { threshold, attack, hold, release } = activeGate;
const roll = Math.max(20, (attack + hold + release) * 0.08);
const t0 = 0;
const t1 = t0 + roll; // threshold crossed, attack begins
const t2 = t1 + attack; // fully open
const t3 = t2 + hold; // hold elapses, release begins
const t4 = t3 + release; // fully closed
const t5 = t4 + roll;
return { threshold, t0, t1, t2, t3, t4, t5 };
}, [activeGate.threshold, activeGate.attack, activeGate.hold, activeGate.release]);
return (
<>
<div className="linear-graph-section">
<div className="gate-graph__header">
<span className="gate-graph__title">Gate envelope</span>
<span className="gate-graph__channel">{activeInputState.name}</span>
</div>
<LinearGraph
xMin={envelope.t0}
xMax={envelope.t5}
yMin={-90}
yMax={0}
xLabel="ms"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ toPx, ctx }) => {
const { threshold, t0, t1, t2, t3, t4, t5 } = envelope;
// Threshold reference line
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [tx0, ty] = toPx(t0, threshold);
const [tx1] = toPx(t5, threshold);
ctx.beginPath();
ctx.moveTo(tx0, ty);
ctx.lineTo(tx1, ty);
ctx.stroke();
ctx.setLineDash([]);
// Gain envelope: closed -> attack -> hold -> release -> closed
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
const points: [number, number][] = [
[t0, -90],
[t1, -90],
[t2, 0],
[t3, 0],
[t4, -90],
[t5, -90],
];
ctx.beginPath();
points.forEach(([t, g], i) => {
const [px, py] = toPx(t, g);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="gate-section">
<div className="gate-panels">
<ChannelGroup title="Input">
{inputs.map((input) => (
<GatePanel
key={String(input)}
channel={input}
dsp={dsp}
setGate={handleSetGate}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default GateSection;
@@ -196,11 +196,27 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
}
// clip to plot area so user drawing can't bleed into labels
const clipPad = 2;
ctx.save();
ctx.beginPath();
ctx.rect(plot.x, plot.y, plot.width, plot.height);
ctx.rect(
plot.x - clipPad,
plot.y - clipPad,
plot.width + clipPad * 2,
plot.height + clipPad * 2
);
ctx.clip();
// draw the frame border first, so data drawn on the edge sits on top of it
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showGrid) {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
+22 -14
View File
@@ -10,17 +10,19 @@
}
.gauge-container {
--s: min(calc(100cqw / 70), calc(100cqh / 240));
--s: min(calc(100cqw / 70), calc(100cqh / var(--min-height-units, 180)));
position: absolute;
bottom: 0;
left: 50%;
width: calc(70 * var(--s));
height: calc(240 * var(--s));
height: 100%;
max-height: calc(var(--max-height-units, 240) * var(--s));
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
transform: translateX(-50%);
@@ -32,7 +34,9 @@
position: relative;
width: calc(70 * var(--s));
height: calc(210 * var(--s));
flex: 1 1 auto;
max-height: calc(210 * var(--s));
min-height: 0;
display: flex;
justify-content: center;
@@ -63,7 +67,7 @@
height: 100%;
background: var(--border-hairline);
border-radius: var(--radius-sm);
border-radius: calc(6 * var(--s));
}
.thumb {
@@ -77,8 +81,8 @@
border-radius: 50%;
background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand);
box-shadow: var(--shadow-sm);
border: calc(2 * var(--s)) solid var(--accent-brand);
box-shadow: 0 calc(2 * var(--s)) calc(6 * var(--s)) rgba(0, 0, 0, 0.2);
}
.ticks {
@@ -98,11 +102,15 @@
height: calc(2 * var(--s));
background: var(--border-hairline);
border-radius: var(--radius-sm);
border-radius: calc(6 * var(--s));
}
.left { left: calc(8 * var(--s)); }
.right { right: calc(8 * var(--s)); }
.left {
left: calc(8 * var(--s));
}
.right {
right: calc(8 * var(--s));
}
.value {
width: 100%;
@@ -120,12 +128,12 @@
white-space: nowrap;
color: var(--accent-brand);
font-size: var(--font-xxs);
font-size: calc(12 * var(--s));
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
box-shadow: 0 calc(2 * var(--s)) calc(6 * var(--s)) rgba(0, 0, 0, 0.2);
border-radius: calc(8 * var(--s));
overflow: hidden;
}
@@ -138,8 +146,8 @@
text-align: center;
background: transparent;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
border: calc(1 * var(--s)) solid var(--border-hairline);
border-radius: calc(6 * var(--s));
appearance: none;
}
+76 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css';
@@ -12,13 +12,33 @@ type VerticalSliderProps = {
value: number;
onChange?: (value: number) => Promise<boolean>;
/**
* Fires synchronously on every live value change — while dragging, on each
* keypress — *before* the value is committed via onChange. Use this to
* drive UI that should track the slider in real time (e.g. a preview
* graph), without waiting for the (async) commit round-trip.
*/
onInput?: (value: number) => void;
unit?: string;
minAspectHeight?: number;
maxAspectHeight?: number;
className?: string;
style?: React.CSSProperties;
};
// Design constants: at the original 70x240 ratio the track is 210 units tall
// and holds 15 ticks (2 units tall) laid out with space-between, which works
// out to a ~13 unit gap -> a ~15 unit center-to-center pitch. We reuse that
// pitch to decide how many ticks fit in whatever track height we actually get.
const DESIGN_TRACK_WIDTH_UNITS = 70;
const TICK_HEIGHT_UNITS = 2;
const TICK_PITCH_UNITS = 15;
const MIN_TICKS = 3;
const MAX_TICKS = 15;
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
@@ -33,19 +53,59 @@ export default function VerticalSlider({
switchStep,
value,
onChange,
onInput,
unit = '',
minAspectHeight = 180,
maxAspectHeight = 240,
className,
style,
}: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
const [tickCount, setTickCount] = useState(MAX_TICKS);
useEffect(() => {
if (dragging.current || keyboardEditing.current || editingText !== null) {
return;
}
setDisplayValue(value);
dragValue.current = value;
}, [value]);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
// .gauge-track's width is always calc(70 * --s), so this recovers --s in px.
const s = w / DESIGN_TRACK_WIDTH_UNITS;
const heightUnits = h / s;
const raw = 1 + (heightUnits - TICK_HEIGHT_UNITS) / TICK_PITCH_UNITS;
const n = Math.max(MIN_TICKS, Math.min(MAX_TICKS, Math.round(raw)));
setTickCount(n);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, []);
function getStep(value: number) {
if (switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue) {
@@ -75,6 +135,7 @@ export default function VerticalSlider({
dragValue.current = next;
setDisplayValue(next);
onInput?.(next);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
@@ -120,6 +181,7 @@ export default function VerticalSlider({
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
@@ -155,6 +217,7 @@ export default function VerticalSlider({
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
@@ -170,12 +233,14 @@ export default function VerticalSlider({
const next = normalizeValue(parsed);
setDisplayValue(next);
onInput?.(next);
const success = await onChange?.(next);
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
}
@@ -185,17 +250,17 @@ export default function VerticalSlider({
const percent = (displayValue - min) / (max - min);
const containerStyle = {
'--min-height-units': minAspectHeight,
'--max-height-units': maxAspectHeight,
} as React.CSSProperties;
return (
<div
className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
>
<div
className="gauge-container"
>
<div className="gauge-track">
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container" style={containerStyle}>
<div className="gauge-track" ref={trackRef}>
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
@@ -216,7 +281,7 @@ export default function VerticalSlider({
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
+1 -1
View File
@@ -139,7 +139,7 @@
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--font-md);
font-size: var(--font-xs);
cursor: pointer;
box-sizing: border-box;
scroll-snap-align: start;
+2 -10
View File
@@ -45,11 +45,7 @@ function Tab({
return (
<div
className={[
'tab',
active && 'tab--active',
dimmed && 'tab--dimmed',
]
className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
.filter(Boolean)
.join(' ')}
data-step={step}
@@ -62,11 +58,7 @@ function Tab({
<button
type="button"
className="tab-led"
aria-label={
isConnected
? `Disconnect ${dsp.name}`
: `Connect ${dsp.name}`
}
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
+70 -2
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { DSP, AppPage } from '../types/types';
import { Channel } from '../types/dsp408State';
import { Channel, InputChannel, defaultDSPState } from '../types/dsp408State';
import {
createDSP408,
@@ -37,6 +37,13 @@ export type DSP408Device = {
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
setGate(
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
): Promise<boolean>;
};
// ============================================================
@@ -245,7 +252,7 @@ export function useDSP408(
id,
...dsp,
status: 'disconnected',
state: null,
state: defaultDSPState,
meters: null,
pollingPaused: true,
};
@@ -559,6 +566,60 @@ export function useDSP408(
});
}
// ==========================================================
// Gate
// ==========================================================
async function setGate(
dsp: DSP,
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_gate', {
id: dsp.id,
channel: inputChannel,
threshold,
attack,
hold,
release,
});
if (!success) {
notify('Failed to change gate settings', '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,
input_states: {
...currentConfig.input_states,
[inputChannel]: {
...currentConfig.input_states[inputChannel],
gate: { threshold, attack, hold, release },
},
},
},
},
};
});
notify('Gate updated', 'success', dsp.name);
return true;
} catch (err) {
notify(`Gate update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Return API
// ==========================================================
@@ -572,6 +633,13 @@ export function useDSP408(
setGain: (channel: Channel, gain: number) => setGain(dsp, channel, gain),
setMute: (channel: Channel, muted: boolean) => setMute(dsp, channel, muted),
setInverse: (channel: Channel, inverted: boolean) => setInverse(dsp, channel, inverted),
setGate: (
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
) => setGate(dsp, inputChannel, threshold, attack, hold, release),
};
}
+1 -1
View File
@@ -177,7 +177,7 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) {
);
case 'gate':
return <GateSection dsp={dsp} />;
return <GateSection dsp={dsp} set_gate={dsp408.setGate} />;
default:
return null;
+127
View File
@@ -195,3 +195,130 @@ export type DSPState = {
export interface Meters {
levels: number[];
}
/* -- Default Value -- */
const createDefaultGate = (): Gate => ({
attack: 10,
release: 10,
hold: 10,
threshold: 0,
});
const createDefaultGraphicEQ = (): GraphicEQ => ({
gains: Array(31).fill(0),
bypass: false,
});
const createDefaultPEQ = (): PEQ => ({
gain: 0,
frequency: 1000,
q: 1,
filter_type: PEQFilter.Peak,
bypass: false,
});
const createDefaultPEQChain = (): PEQChain => ({
bands: Array.from({ length: 8 }, createDefaultPEQ),
bypass: false,
});
const createDefaultCrossover = (): Crossover => ({
frequency: 20000,
slope: CrossoverFilter.Bypass,
});
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
high_pass: createDefaultCrossover(),
low_pass: createDefaultCrossover(),
});
const createDefaultInputChannelState = (name: string): InputChannelState => ({
name,
gain: 0,
phase_inverted: false,
delay: 0,
gate: createDefaultGate(),
geq: createDefaultGraphicEQ(),
peq_chain: createDefaultPEQChain(),
crossover: createDefaultCrossoverFilters(),
linked_channels: [],
mute: false,
});
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [],
gains: [],
},
crossover: createDefaultCrossoverFilters(),
peq_chain: createDefaultPEQChain(),
compressor: {
threshold: 0,
ratio: Ratio.Ratio1_1,
attack: 0,
release: 0,
knee: 0,
},
limiter: {
threshold: 0,
attack: 0,
release: 0,
},
gain: 0,
phase_inverted: false,
delay: 0,
linked_channels: [],
mute: false,
});
export const defaultDSPState: DSPState = {
name: 'Default',
flags: {
is_locked: false,
},
presets: {
current_index: 0,
names: ['Default'],
modified: [false],
},
current_config: {
input_source: {
type: 'Analog',
frequency: '48kHz',
},
delay_unit: DelayUnit.Millisecond,
input_states: {
[InputChannel.InA]: createDefaultInputChannelState('In A'),
[InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'),
},
output_states: {
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
},
},
};
+1 -1
View File
@@ -10,7 +10,7 @@ export type DSP = {
port: number;
deviceId: number;
status: DSPStatus;
state: DSPState | null;
state: DSPState;
meters: Meters | null;
pollingPaused: boolean;
};