feat: gate sectioon
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -10,7 +10,7 @@ export type DSP = {
|
||||
port: number;
|
||||
deviceId: number;
|
||||
status: DSPStatus;
|
||||
state: DSPState | null;
|
||||
state: DSPState;
|
||||
meters: Meters | null;
|
||||
pollingPaused: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user