feat: add compressor + fix ui for all sections

This commit is contained in:
2026-08-06 14:13:04 +02:00
parent e110a28f80
commit 78ea04453e
23 changed files with 1721 additions and 665 deletions
+26
View File
@@ -265,3 +265,29 @@ pub async fn set_gate(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
#[tauri::command]
pub async fn set_compressor(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
ratio: types::Ratio,
attack: u16,
release: u16,
knee: u16,
threshold: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_compressor(
channel,
ratio,
attack,
release,
knee,
threshold,
)
.map_err(|e| e.to_string())
})
}
+1
View File
@@ -21,6 +21,7 @@ pub fn run() {
commands::get_meter_levels, commands::get_meter_levels,
commands::set_current_preset_name, commands::set_current_preset_name,
commands::set_gate, commands::set_gate,
commands::set_compressor,
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+1 -1
View File
@@ -15,7 +15,7 @@
"title": "thomann-dsp-app", "title": "thomann-dsp-app",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 900, "minWidth": 1000,
"minHeight": 600 "minHeight": 600
} }
], ],
+27
View File
@@ -0,0 +1,27 @@
export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 3L13 10H3L8 3Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 13L3 6H13L8 13Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
+111
View File
@@ -0,0 +1,111 @@
.meter-bar {
display: flex;
flex-direction: column;
align-items: left;
gap: 0.3cqh;
}
.meter-bar__title {
font-size: 1.5cqh;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
text-align: left;
justify-content: left;
}
.meter-bar__body {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.3cqh;
flex: 1 1 auto;
}
.meter-bar__leds-wrap {
position: relative;
width: 3cqh;
flex: 1 1 auto;
}
/* All rows — the 2 indicator LEDs and every level segment — are direct
siblings in this one flex column with equal flex weight, so they're
guaranteed the exact same height. No row gets a hardcoded pixel height. */
.meter-bar__leds {
display: flex;
flex-direction: column;
gap: 0.5cqh;
width: 100%;
height: 100%;
}
.meter-bar__led {
flex: 1 1 0;
min-height: 0;
width: 100%;
border-radius: 0.5cqh;
box-sizing: border-box;
transition: background-color 60ms linear;
}
.meter-bar__led--indicator {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
/* Peak-hold marker: a thin line overlaid on the ladder at the peak's position,
independent of which LEDs are lit by rms. */
.meter-bar__peak-marker {
position: absolute;
left: -0.5cqh;
right: -0.5cqh;
height: 0.3cqh;
min-height: 1px;
background-color: #e5e7eb;
box-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
transform: translateY(0.25cqh);
pointer-events: none;
}
/* Scale sits in the same row as leds-wrap, same height, so ticks positioned
by percentage line up with the exact dB position on the ladder. */
.meter-bar__scale {
position: relative;
width: 3.5cqh;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.4);
}
.meter-bar__scale-tick {
position: absolute;
left: 0;
transform: translateY(50%);
white-space: nowrap;
}
.meter-bar__scale-tick--end {
color: rgba(255, 255, 255, 0.55);
}
.meter-bar__value {
padding-top: 0.5cqh;
font-size: 1.2cqh;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.7);
}
.meter-bar__indicator-row {
display: flex;
align-items: center;
gap: 0.1cqh;
flex: 1 1 0;
min-height: 0;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.meter-bar__indicator-row .meter-bar__led {
flex: 0 0 auto;
width: 3cqh;
height: 100%;
}
+165
View File
@@ -0,0 +1,165 @@
import './MeterBar.css';
export interface Meter {
rms: number;
peak: number;
clip?: boolean;
}
type MeterBarProps = {
label: string;
meter: Meter;
min?: number;
max?: number;
segments?: number;
/** Intermediate dB values to mark on the scale, e.g. [0] or [-20, -6, 0]. */
ticks?: number[];
};
function segmentColor(posRatioFromBottom: number) {
if (posRatioFromBottom > 0.9) return '#f87171'; // red — near clip
if (posRatioFromBottom > 0.7) return '#fbbf24'; // yellow — hot
return '#4ade80'; // green — normal
}
// Formats a dB value with an explicit '+' sign for positive values (e.g. +12, -60, 0)
function formatDb(v: number, decimals = 0) {
if (!Number.isFinite(v)) return '—';
const fixed = v.toFixed(decimals);
return v > 0 ? `+${fixed}` : fixed;
}
function MeterBar({
label,
meter,
min = -60,
max = +10,
segments = 7,
ticks = [0, -50, -40, -30, -20, -10],
}: MeterBarProps) {
const { rms, peak, clip } = meter;
// Maps a raw dB value to a 0..1 ratio from the bottom of the ladder, clamped to range.
const ratioOf = (v: number) => {
const clamped = Math.min(max, Math.max(min, v));
return (clamped - min) / (max - min);
};
// --- RMS ladder (drives which LEDs are lit) ---
const rmsRatio = ratioOf(rms);
const litCount = Math.round(rmsRatio * segments);
// --- Peak marker (position only, doesn't affect lit count) ---
const peakRatio = ratioOf(peak);
// --- Intermediate tick marks on the scale, e.g. a 0 dB reference line ---
const visibleTicks = ticks.filter((t) => t > min && t < max);
// --- Red "over" LED: lit whenever rms or peak crosses max ---
const isOverMax = rms >= max || peak >= max;
// --- Orange "limit" LED: lit strictly from the meter's own clip flag.
// clip is optional — some meters simply don't report a limiter/clip state.
const hasLimiter = clip !== undefined;
const isClipping = clip === true;
// The over-led and limit-led are rendered as two extra rows in the very
// same flex column as the level segments (see meter-bar__leds below), so
// they get identical row height automatically instead of a hardcoded one.
// Because of that, the ladder itself only occupies `segments / totalRows`
// of the column — the rest of the math (peak marker, scale ticks) has to
// scale down to match.
const totalRows = segments + 2;
const ladderFraction = segments / totalRows;
const toColumnPercent = (ratio: number) => ratio * ladderFraction * 100;
return (
<div className="meter-bar">
<div className="meter-bar__title">{label}</div>
<div className="meter-bar__body">
<div className="meter-bar__leds-wrap">
<div className="meter-bar__leds">
<div
className="meter-bar__indicator-row"
style={{ visibility: hasLimiter ? 'visible' : 'hidden' }}
>
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isClipping ? '#fb923c' : 'rgba(255,255,255,0.08)',
}}
title="Limit"
/>
<span>Limit</span>
</div>
<div className="meter-bar__indicator-row">
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isOverMax ? '#f87171' : 'rgba(255,255,255,0.08)',
}}
title="Clip"
/>
<span>Clip</span>
</div>
{Array.from({ length: segments }).map((_, i) => {
// i=0 renders at the top; convert to "position from bottom" for lighting + color
const posFromBottom = segments - 1 - i;
const lit = posFromBottom < litCount;
return (
<div
key={i}
className="meter-bar__led"
style={{
backgroundColor: lit
? segmentColor(posFromBottom / segments)
: 'rgba(255,255,255,0.08)',
}}
/>
);
})}
</div>
<div
className="meter-bar__peak-marker"
style={{ bottom: `${toColumnPercent(peakRatio)}%` }}
title={`Peak: ${formatDb(peak, 1)} dB`}
/>
</div>
<div className="meter-bar__scale">
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: `${ladderFraction * 100}%` }}
>
{formatDb(max)}
</span>
{visibleTicks.map((t) => (
<span
key={t}
className="meter-bar__scale-tick"
style={{ bottom: `${toColumnPercent(ratioOf(t))}%` }}
>
{formatDb(t)}
</span>
))}
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: '0%' }}
>
{formatDb(min)}
</span>
</div>
</div>
<div className="meter-bar__value">
{Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'}
</div>
</div>
);
}
export default MeterBar;
@@ -0,0 +1,290 @@
.compressor-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);
}
.compressor-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.compressor-panel {
position: relative;
height: 10;
aspect-ratio: 0.5;
flex: 0 0 auto;
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);
}
.compressor-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;
}
.compressor-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'threshold attack'
'steppers release';
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.compressor-panel__cell--threshold {
grid-area: threshold;
}
.compressor-panel__cell--attack {
grid-area: attack;
}
.compressor-panel__cell--release {
grid-area: release;
}
.compressor-panel__cell--steppers {
grid-area: steppers;
}
.compressor-panel__cell--steppers {
flex-direction: column;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__stepper-item {
flex: 1 1 0;
min-height: 0;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.compressor-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.choice-stepper {
width: 100%;
flex: 1 1 auto;
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
background: transparent;
}
.choice-stepper__input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
font-family: var(--font-ui);
font-size: 3cqh;
font-weight: var(--font-weight-semibold);
text-align: center;
color: var(--accent-brand);
background: transparent;
border: none;
border-radius: var(--radius-sm);
padding: 0.5ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-stepper__input:focus {
outline: none;
}
.choice-stepper__arrows {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5cqh;
height: 100%;
box-sizing: border-box;
padding: 1cqh;
padding-left: 0;
}
.choice-stepper__arrow-btn {
aspect-ratio: 1;
height: 4.5cqh;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--accent-brand);
background: transparent;
border-radius: 1cqh;
cursor: pointer;
}
.choice-stepper__arrow-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choice-stepper__arrow-btn svg {
width: 100%;
height: 100%;
}
.compressor-graph__channel {
font-size: var(--font-xs);
color: var(--text-muted);
}
.compressor-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);
}
.compressor-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-meter-panel {
flex: 0 0 auto;
width: fit-content;
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-graph__header {
flex: 0 0 auto;
display: flex;
align-items: baseline;
justify-content: space-between;
font-family: var(--font-ui);
padding: 1cqh;
}
.compressor-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);
}
.compressor-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1cqh;
padding: 1cqh;
}
.compressor-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -0,0 +1,437 @@
// CompressorSection.tsx
import VerticalSlider from '../Slider/VerticalSlider';
import { OutputChannel, Ratio } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../assets/icons/Triangle';
import './CompressorSection.css';
// Enum order defines stepper order (1:1 -> Limit).
const RATIO_ORDER: Ratio[] = [
Ratio.Ratio1_1,
Ratio.Ratio1_1_1,
Ratio.Ratio1_1_3,
Ratio.Ratio1_1_5,
Ratio.Ratio1_1_7,
Ratio.Ratio1_2,
Ratio.Ratio1_2_5,
Ratio.Ratio1_3,
Ratio.Ratio1_3_5,
Ratio.Ratio1_4,
Ratio.Ratio1_5,
Ratio.Ratio1_6,
Ratio.Ratio1_8,
Ratio.Ratio1_10,
Ratio.Ratio1_20,
Ratio.Limit,
];
const RATIO_LABEL: Record<Ratio, string> = {
[Ratio.Ratio1_1]: '1:1',
[Ratio.Ratio1_1_1]: '1.1:1',
[Ratio.Ratio1_1_3]: '1.3:1',
[Ratio.Ratio1_1_5]: '1.5:1',
[Ratio.Ratio1_1_7]: '1.7:1',
[Ratio.Ratio1_2]: '2:1',
[Ratio.Ratio1_2_5]: '2.5:1',
[Ratio.Ratio1_3]: '3:1',
[Ratio.Ratio1_3_5]: '3.5:1',
[Ratio.Ratio1_4]: '4:1',
[Ratio.Ratio1_5]: '5:1',
[Ratio.Ratio1_6]: '6:1',
[Ratio.Ratio1_8]: '8:1',
[Ratio.Ratio1_10]: '10:1',
[Ratio.Ratio1_20]: '20:1',
[Ratio.Limit]: 'Limit',
};
// Numeric ratio used only for drawing the transfer curve; Limit ~= brickwall.
const RATIO_NUMERIC: Record<Ratio, number> = {
[Ratio.Ratio1_1]: 1,
[Ratio.Ratio1_1_1]: 1.1,
[Ratio.Ratio1_1_3]: 1.3,
[Ratio.Ratio1_1_5]: 1.5,
[Ratio.Ratio1_1_7]: 1.7,
[Ratio.Ratio1_2]: 2,
[Ratio.Ratio1_2_5]: 2.5,
[Ratio.Ratio1_3]: 3,
[Ratio.Ratio1_3_5]: 3.5,
[Ratio.Ratio1_4]: 4,
[Ratio.Ratio1_5]: 5,
[Ratio.Ratio1_6]: 6,
[Ratio.Ratio1_8]: 8,
[Ratio.Ratio1_10]: 10,
[Ratio.Ratio1_20]: 20,
[Ratio.Limit]: 1000,
};
const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
const COMPRESSOR_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Adjust to match dsp.meters.channels ordering.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
type CompressorValues = {
ratio: Ratio;
attack: number;
release: number;
knee: number;
threshold: number;
};
type SetCompressorFn = (
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
threshold: number
) => Promise<boolean>;
function ChoiceStepper<T>({
value,
values,
formatLabel,
onChange,
}: {
value: T;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => void;
}) {
const index = values.indexOf(value);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const [text, setText] = useState(formatLabel(value));
useEffect(() => {
setText(formatLabel(value));
}, [value, formatLabel]);
const step = (dir: 1 | -1) => {
const currentIndex = index === -1 ? 0 : index;
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + dir));
if (nextIndex !== currentIndex) onChange(values[nextIndex]);
};
const commitText = () => {
const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) {
onChange(match);
} else {
setText(formatLabel(value));
}
};
return (
<div className="choice-stepper">
<input
className="choice-stepper__input"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
step(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
step(-1);
}
}}
/>
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(1)}
disabled={!canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(-1)}
disabled={!canDown}
>
<TriangleDownIcon />
</Button>
</div>
</div>
);
}
function CompressorPanel({
channel,
dsp,
setCompressor,
onLiveValue,
}: {
channel: OutputChannel;
dsp: DSP;
setCompressor: SetCompressorFn;
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>) => {
const next = { ...comp, ...overrides };
return setCompressor(channel, next.ratio, next.attack, next.release, next.knee, next.threshold);
};
return (
<div className="compressor-panel">
<div className="compressor-panel__title">{outputState.name}</div>
<div className="compressor-panel__grid">
{COMPRESSOR_SLIDER_PARAMS.map((param) => (
<div
className={`compressor-panel__cell compressor-panel__cell--${param.key}`}
key={param.key}
>
<VerticalSlider
min={param.min}
max={param.max}
step={param.step}
unit={param.unit}
value={comp[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<CompressorValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="compressor-panel__cell-label">{param.label}</div>
</div>
))}
<div className="compressor-panel__cell compressor-panel__cell--steppers">
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.ratio}
values={RATIO_ORDER}
formatLabel={(v) => RATIO_LABEL[v]}
onChange={(value) => {
onLiveValue(channel, 'ratio', value);
commit({ ratio: value });
}}
/>
<div className="compressor-panel__cell-label">Ratio</div>
</div>
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.knee}
values={KNEE_VALUES}
formatLabel={(v) => `${v} dB`}
onChange={(value) => {
onLiveValue(channel, 'knee', value);
commit({ knee: value });
}}
/>
<div className="compressor-panel__cell-label">Knee</div>
</div>
</div>
</div>
</div>
);
}
// Soft-knee compressor transfer curve, in dB.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2;
if (knee <= 0 || input <= kneeStart) {
return input <= threshold ? input : threshold + (input - threshold) / ratio;
}
if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio;
}
const x = input - kneeStart;
return input + ((1 / ratio - 1) * x * x) / (2 * knee);
}
function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) {
const outputs = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const [activeChannel, setActiveChannel] = useState<OutputChannel>(outputs[0]);
const [liveComp, setLiveComp] = useState<Partial<Record<OutputChannel, CompressorValues>>>({});
const handleLiveValue = (
channel: OutputChannel,
key: CompressorParamKey,
value: number | Ratio
) => {
setActiveChannel(channel);
setLiveComp((prev) => {
const base =
prev[channel] ??
(dsp.state.current_config.output_states[channel].compressor as CompressorValues);
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetCompressor: SetCompressorFn = async (
channel,
ratio,
attack,
release,
knee,
threshold
) => {
setActiveChannel(channel);
const success = await set_compressor(channel, ratio, attack, release, knee, threshold);
if (success) {
setLiveComp((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeOutputState = dsp.state.current_config.output_states[activeChannel];
const activeComp = liveComp[activeChannel] ?? (activeOutputState.compressor as CompressorValues);
const curvePoints = useMemo(() => {
const { threshold, ratio, knee } = activeComp;
const ratioNumeric = RATIO_NUMERIC[ratio];
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
}
return points;
}, [activeComp.threshold, activeComp.ratio, activeComp.knee]);
return (
<>
<div className="compressor-linear-graph-section">
<div className="compressor-graph-panel">
<div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div>
<LinearGraph
xMin={-60}
xMax={0}
yMin={-60}
yMax={0}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
// Unity reference line
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60);
const [ux1, uy1] = toPx(0, 0);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
ctx.stroke();
ctx.setLineDash([]);
// Threshold marker
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -60);
const [tx1, ty1] = toPx(activeComp.threshold, 0);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
ctx.stroke();
ctx.setLineDash([]);
// Compression curve
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
curvePoints.forEach(([x, y], i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="compressor-meter-panel">
<div className="compressor-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
);
})}
</div>
</div>
</div>
<div className="compressor-section">
<div className="compressor-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<CompressorPanel
key={String(output)}
channel={output}
dsp={dsp}
setCompressor={handleSetCompressor}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default CompressorSection;
@@ -1,45 +1,6 @@
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
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: var(--space-2);
background: var(--bg-void);
padding-bottom: var(--space-2);
}
.gain-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);
}
/* Container */
.gain-section { .gain-section {
container-type: size; container-type: size;
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
@@ -67,20 +28,78 @@
overflow-x: auto; overflow-x: auto;
} }
/* --- graph strip, sits above the gain section --- .gain-panel {
Requires the shared parent (whatever renders <GainSection />) to be position: relative;
position: relative — it already must be, for .gain-section's absolute height: 100%;
bottom:0 to anchor correctly. */ aspect-ratio: 1 / 4;
.linear-graph-section { flex: 0 0 auto;
position: absolute;
top: 0; display: flex;
left: 0; flex-direction: column;
right: 0; align-items: center;
bottom: var(--dsp-control-heigth); 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;
}
.gain-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;
}
.gain-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; box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel); background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */ }
.gain-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);
}
.gain-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);
}
.gain-graph-panel > .container {
flex: 1;
min-height: 0;
} }
@@ -4,12 +4,8 @@ import { DSP } from '../../../types/types';
import Button from '../../Button/Button'; import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup'; import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph'; import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useRef, useState } from 'react';
import './GainSection.css'; import './GainSection.css';
const PANEL_DESIGN_WIDTH = 90;
const PANEL_DESIGN_HEIGHT = 400;
function GainPanel({ function GainPanel({
dsp, dsp,
channel, channel,
@@ -34,9 +30,6 @@ function GainPanel({
const inverted = channelState?.phase_inverted ?? false; const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0; const gain = channelState?.gain ?? 0;
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => { const toggleMute = async () => {
await setMute(channel, !muted); await setMute(channel, !muted);
}; };
@@ -45,59 +38,53 @@ function GainPanel({
await setInverse(channel, !inverted); await setInverse(channel, !inverted);
}; };
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 ( return (
<div className="gain-panel-viewport" ref={viewportRef}> <div className="gain-panel">
<div <div className="gain-panel__title">{title}</div>
className="gain-panel"
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
/>
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{ style={{
width: PANEL_DESIGN_WIDTH, width: '85%',
height: PANEL_DESIGN_HEIGHT, paddingLeft: 0,
transform: `translate(-50%, -50%) scale(${scale})`, paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}} }}
> >
<div className="gain-panel__title">{title}</div> {muted ? 'Muted' : 'Mute '}
</Button>
<VerticalSlider <Button
min={-60} variant="toggle"
max={12} className={inverted ? 'active-warning' : ''}
step={0.5} onClick={toggleInverse}
switchStepValue={-10} style={{
switchStep={0.1} width: '85%',
unit=" dB" paddingLeft: 0,
value={gain} paddingRight: 0,
onChange={(value) => setGain(channel, value)} fontSize: '4cqh',
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink paddingTop: '2cqh',
/> paddingBottom: '2cqh',
minHeight: 0,
<Button }}
variant="toggle" >
className={muted ? 'active-danger' : ''} {inverted ? 'Inverted' : 'Invert'}
onClick={toggleMute} </Button>
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</div> </div>
); );
} }
@@ -113,11 +100,11 @@ function GainSection({
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) { }) {
const inputs: { channel: Channel; title: string }[] = [ const inputs: InputChannel[] = [
{ channel: { Input: InputChannel.InA }, title: 'In A' }, InputChannel.InA,
{ channel: { Input: InputChannel.InB }, title: 'In B' }, InputChannel.InB,
{ channel: { Input: InputChannel.InC }, title: 'In C' }, InputChannel.InC,
{ channel: { Input: InputChannel.InD }, title: 'In D' }, InputChannel.InD,
]; ];
const outputs: { channel: Channel; title: string }[] = [ const outputs: { channel: Channel; title: string }[] = [
@@ -133,40 +120,63 @@ function GainSection({
return ( return (
<> <>
<div className="linear-graph-section"> <div className="gain-linear-graph-section">
<LinearGraph <div className="gain-graph-panel">
xMin={20} <div className="gain-graph-title">Frequency Response</div>
xMax={20000} <LinearGraph
yMin={-24} xMin={20}
yMax={24} xMax={20000}
yStep={6} yMin={-24}
xLabel="Hz" yMax={24}
yLabel="dB" yStep={6}
draw={({ ctx, toPx }) => { xLabel="Hz"
// placeholder flat 0 dB response — swap for real EQ/filter data. yLabel="dB"
// NOTE: this maps x linearly; for a real frequency response you'll draw={({ ctx, toPx }) => {
// likely want a log-frequency x-axis instead (see chat notes). ctx.strokeStyle = '#4ade80';
ctx.strokeStyle = '#4ade80'; ctx.lineWidth = 2;
ctx.lineWidth = 2; const [x0, y0] = toPx(20, 0);
const [x0, y0] = toPx(20, 0); const [x1, y1] = toPx(20000, 0);
const [x1, y1] = toPx(20000, 0); ctx.beginPath();
ctx.beginPath(); ctx.moveTo(x0, y0);
ctx.moveTo(x0, y0); ctx.lineTo(x1, y1);
ctx.lineTo(x1, y1); ctx.stroke();
ctx.stroke(); }}
}} />
/> </div>
<div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="deg"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div> </div>
<div className="gain-section"> <div className="gain-section">
<div className="gain-panels"> <div className="gain-panels">
<ChannelGroup title="Input"> <ChannelGroup title="Input">
{inputs.map(({ channel, title }) => ( {inputs.map((channel) => (
<GainPanel <GainPanel
key={title} key={channel}
dsp={dsp} dsp={dsp}
channel={channel} channel={{ Input: channel }}
title={title} title={dsp.state.current_config.input_states[channel].name}
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
+131 -99
View File
@@ -1,110 +1,13 @@
/* GateSection.css */ /* 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 { .gate-section {
container-type: size; container-type: size;
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: var(--dsp-control-heigth); height: 40%;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -125,3 +28,132 @@
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
} }
.gate-panel {
position: relative;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
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);
}
.gate-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;
}
.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: 2cqh;
padding: 2cqh;
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: 1cqh;
}
.gate-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.gate-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);
}
.gate-graph-panel {
flex: 1 1 auto; /* take remaining space */
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-meter-panel {
flex: 0 0 auto; /* only required width */
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-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);
}
.gate-graph-panel > .container {
flex: 1;
min-height: 0;
}
.gate-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: 1cqh;
padding: 1cqh;
}
+95 -118
View File
@@ -4,12 +4,10 @@ import { InputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types'; import { DSP } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup'; import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph'; import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useMemo, useRef, useState } from 'react'; import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './GateSection.css'; import './GateSection.css';
const PANEL_DESIGN_WIDTH = 160;
const PANEL_DESIGN_HEIGHT = 400;
const GATE_PARAMS = [ const GATE_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 0, step: 0.5, unit: ' dB' }, { 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: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
@@ -17,6 +15,13 @@ const GATE_PARAMS = [
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' }, { key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const; ] as const;
const INPUT_METER_INDEX: Record<InputChannel, number> = {
[InputChannel.InA]: 0,
[InputChannel.InB]: 1,
[InputChannel.InC]: 2,
[InputChannel.InD]: 3,
} as const;
type GateParamKey = (typeof GATE_PARAMS)[number]['key']; type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
type GateValues = { type GateValues = {
@@ -45,62 +50,37 @@ function GatePanel({
setGate: SetGateFn; setGate: SetGateFn;
onLiveValue: (channel: InputChannel, key: GateParamKey, value: number) => void; 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 inputState = dsp.state.current_config.input_states[channel];
const gate = inputState.gate; 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 ( return (
<div className="gate-panel-viewport" ref={viewportRef}> <div className="gate-panel">
<div <div className="gate-panel__title">{inputState.name}</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"> <div className="gate-panel__grid">
{GATE_PARAMS.map((param) => ( {GATE_PARAMS.map((param) => (
<div className="gate-panel__cell" key={param.key}> <div className="gate-panel__cell" key={param.key}>
<VerticalSlider <VerticalSlider
min={param.min} min={param.min}
max={param.max} max={param.max}
step={param.step} step={param.step}
unit={param.unit} unit={param.unit}
value={gate[param.key]} value={gate[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)} onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => { onChange={(value) => {
return setGate( return setGate(
channel, channel,
param.key === 'threshold' ? value : gate.threshold, param.key === 'threshold' ? value : gate.threshold,
param.key === 'attack' ? value : gate.attack, param.key === 'attack' ? value : gate.attack,
param.key === 'hold' ? value : gate.hold, param.key === 'hold' ? value : gate.hold,
param.key === 'release' ? value : gate.release param.key === 'release' ? value : gate.release
); );
}} }}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
maxAspectHeight={165} />
/> <div className="gate-panel__cell-label">{param.label}</div>
<div className="gate-panel__cell-label">{param.label}</div> </div>
</div> ))}
))}
</div>
</div> </div>
</div> </div>
); );
@@ -109,14 +89,7 @@ function GatePanel({
function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) { function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const inputs = [InputChannel.InA, InputChannel.InB, InputChannel.InC, InputChannel.InD]; 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]); 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 [liveGate, setLiveGate] = useState<Partial<Record<InputChannel, GateValues>>>({});
const handleLiveValue = (channel: InputChannel, key: GateParamKey, value: number) => { const handleLiveValue = (channel: InputChannel, key: GateParamKey, value: number) => {
@@ -132,8 +105,6 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const success = await set_gate(channel, threshold, attack, hold, release); const success = await set_gate(channel, threshold, attack, hold, release);
if (success) { if (success) {
// Backend confirmed — drop the local override and let dsp.state (the
// source of truth) take over again.
setLiveGate((prev) => { setLiveGate((prev) => {
if (!(channel in prev)) return prev; if (!(channel in prev)) return prev;
const next = { ...prev }; const next = { ...prev };
@@ -148,72 +119,78 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const activeInputState = dsp.state.current_config.input_states[activeChannel]; const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGate = liveGate[activeChannel] ?? activeInputState.gate; 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 envelope = useMemo(() => {
const { threshold, attack, hold, release } = activeGate; const { threshold, attack, hold, release } = activeGate;
const roll = Math.max(20, (attack + hold + release) * 0.08); const roll = Math.max(20, (attack + hold + release) * 0.08);
const t0 = 0; const t0 = 0;
const t1 = t0 + roll; // threshold crossed, attack begins const t1 = t0 + roll;
const t2 = t1 + attack; // fully open const t2 = t1 + attack;
const t3 = t2 + hold; // hold elapses, release begins const t3 = t2 + hold;
const t4 = t3 + release; // fully closed const t4 = t3 + release;
const t5 = t4 + roll; const t5 = t4 + roll;
return { threshold, t0, t1, t2, t3, t4, t5 }; return { threshold, t0, t1, t2, t3, t4, t5 };
}, [activeGate.threshold, activeGate.attack, activeGate.hold, activeGate.release]); }, [activeGate.threshold, activeGate.attack, activeGate.hold, activeGate.release]);
return ( return (
<> <>
<div className="linear-graph-section"> <div className="gate-linear-graph-section">
<div className="gate-graph__header"> <div className="gate-graph-panel">
<span className="gate-graph__title">Gate envelope</span> <div className="gate-graph-title">Frequency Response</div>
<span className="gate-graph__channel">{activeInputState.name}</span> <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;
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([]);
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>
<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 <div className="gate-meter-panel">
ctx.strokeStyle = '#fbbf24'; <div className="gate-graph__meters">
ctx.lineWidth = 1; {inputs.map((input) => {
ctx.setLineDash([4, 4]); const inputState = dsp.state.current_config.input_states[input];
const [tx0, ty] = toPx(t0, threshold); const idx = INPUT_METER_INDEX[input];
const [tx1] = toPx(t5, threshold); const meter = dsp.meters.channels[idx];
ctx.beginPath(); return <MeterBar label={inputState.name} meter={meter} />;
ctx.moveTo(tx0, ty); })}
ctx.lineTo(tx1, ty); </div>
ctx.stroke(); </div>
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>
<div className="gate-section"> <div className="gate-section">
@@ -1,89 +0,0 @@
.gain-section {
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;
padding: 0 var(--space-4);
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
height: 100%;
display: flex;
gap: var(--space-4);
overflow-x: auto;
}
.gain-panel-frame {
flex: 0 0 auto;
height: calc(100% - 2 * var(--space-1));
margin: var(--space-1) 0;
}
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
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: var(--space-2);
background: var(--bg-void);
}
.gain-panel__title {
flex: 0 0 auto; /* NEW: never let this shrink */
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);
}
/* --- graph strip, sits above the gain section ---
Requires the shared parent (whatever renders <GainSection />) to be
position: relative — it already must be, for .gain-section's absolute
bottom:0 to anchor correctly. */
.linear-graph-section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: var(--dsp-control-heigth);
box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */
}
@@ -7,23 +7,12 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import './LinearGraph.css'
/**
* Coordinate-space helpers passed into your draw callback.
* Everything here works in DATA units (e.g. seconds, Hz, volts) —
* you never have to think about pixels or devicePixelRatio.
*/
export interface LinearGraphHelpers { export interface LinearGraphHelpers {
/** data (x, y) -> canvas pixel (px, py) */
toPx: (x: number, y: number) => [number, number]; toPx: (x: number, y: number) => [number, number];
/** data x -> pixel x */
toPxX: (x: number) => number; toPxX: (x: number) => number;
/** data y -> pixel y */
toPxY: (y: number) => number; toPxY: (y: number) => number;
/** pixel (px, py) -> data (x, y). Useful for handling clicks/drags. */
toData: (px: number, py: number) => [number, number]; toData: (px: number, py: number) => [number, number];
/** size of the plot area in CSS pixels (inside the axes/padding) */
plot: { x: number; y: number; width: number; height: number }; plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
} }
@@ -31,38 +20,28 @@ export interface LinearGraphHelpers {
export type DrawFn = (helpers: LinearGraphHelpers) => void; export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle { export interface LinearGraphHandle {
/** Force a redraw (e.g. after mutating a ref-held buffer without a re-render) */
redraw: () => void; redraw: () => void;
/** Access the raw canvas, e.g. for exporting a PNG */
getCanvas: () => HTMLCanvasElement | null; getCanvas: () => HTMLCanvasElement | null;
} }
export interface LinearGraphProps { export interface LinearGraphProps {
xMin: number; xMin: number;
xMax: number; xMax: number;
/** grid + tick spacing on x. Omit to auto-pick a "nice" step. */
xStep?: number; xStep?: number;
yMin: number; yMin: number;
yMax: number; yMax: number;
yStep?: number; yStep?: number;
/** Your drawing code. Called every time the graph needs to repaint. */
draw?: DrawFn; draw?: DrawFn;
width?: number; width?: number;
height?: number; height?: number;
/** padding (px) reserved for axis labels, in CSS pixels */
padding?: { top?: number; right?: number; bottom?: number; left?: number }; padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean; showGrid?: boolean;
showAxes?: boolean; showAxes?: boolean;
showTicks?: boolean; showTicks?: boolean;
xLabel?: string; xLabel?: string;
yLabel?: string; yLabel?: string;
/** format a tick value, e.g. (v) => v.toFixed(1) or Hz->kHz */
formatX?: (v: number) => string; formatX?: (v: number) => string;
formatY?: (v: number) => string; formatY?: (v: number) => string;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
@@ -74,7 +53,6 @@ const defaultFormat = (v: number) => {
return parseFloat(v.toFixed(4)).toString(); return parseFloat(v.toFixed(4)).toString();
}; };
/** Pick a "nice" step (1/2/5 * 10^n) that yields ~targetTicks divisions */
function niceStep(min: number, max: number, targetTicks = 8): number { function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1; const range = Math.abs(max - min) || 1;
const raw = range / targetTicks; const raw = range / targetTicks;
@@ -89,12 +67,13 @@ function range(min: number, max: number, step: number): number[] {
const out: number[] = []; const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step; const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) { for (let v = start; v <= max + 1e-9; v += step) {
// snap to avoid float drift like 0.30000000000000004
out.push(Math.round(v / step) * step); out.push(Math.round(v / step) * step);
} }
return out; return out;
} }
const clamp = (min: number, val: number, max: number) => Math.min(max, Math.max(min, val));
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph( export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{ {
xMin, xMin,
@@ -122,20 +101,45 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 }); const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
const pad = useMemo( // "container query" units, computed from the actual rendered size —
// 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two.
const cq = useMemo(() => {
const w = size.width || 1;
const h = size.height || 1;
return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 };
}, [size.width, size.height]);
const pad = useMemo(() => {
const topBase = cq.h * 3;
const rightBase = cq.w * 5;
const bottomBase = cq.h * 4;
const leftBase = cq.w * 2;
const xLabelSpace = xLabel ? cq.h * 7 : 0;
const yLabelSpace = yLabel ? cq.w * 6 : 0;
return {
top: padding?.top ?? topBase,
right: padding?.right ?? rightBase,
bottom: padding?.bottom ?? bottomBase + xLabelSpace,
left: padding?.left ?? leftBase + yLabelSpace,
};
}, [padding, xLabel, yLabel, cq]);
// font / stroke / tick metrics, all derived from container size
const metrics = useMemo(
() => ({ () => ({
top: padding?.top ?? 16, tickFont: cq.min * 3,
right: padding?.right ?? 16, labelFont: cq.min * 4,
bottom: padding?.bottom ?? (xLabel ? 44 : 28), tickLen: cq.min * 1.2,
left: padding?.left ?? (yLabel ? 56 : 44), axisLineWidth: cq.min * 0.4,
yLabelOffset: cq.w * 2,
}), }),
[padding, xLabel, yLabel] [cq]
); );
const effXStep = xStep ?? niceStep(xMin, xMax); const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax); const effYStep = yStep ?? niceStep(yMin, yMax);
// Responsive sizing when width/height aren't fixed
useEffect(() => { useEffect(() => {
if (width && height) return; if (width && height) return;
const el = containerRef.current; const el = containerRef.current;
@@ -194,7 +198,6 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
ctx.fillRect(plot.x, plot.y, plot.width, plot.height); ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
} }
// clip to plot area so user drawing can't bleed into labels
const clipPad = 2; const clipPad = 2;
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
@@ -206,7 +209,6 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
); );
ctx.clip(); ctx.clip();
// draw the frame border first, so data drawn on the edge sits on top of it
ctx.strokeStyle = axisColor; ctx.strokeStyle = axisColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect( ctx.strokeRect(
@@ -235,7 +237,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
if (showAxes) { if (showAxes) {
ctx.strokeStyle = axisColor; ctx.strokeStyle = axisColor;
ctx.lineWidth = 1.5; ctx.lineWidth = metrics.axisLineWidth;
ctx.beginPath(); ctx.beginPath();
if (yMin <= 0 && yMax >= 0) { if (yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5; const py = Math.round(toPxY(0)) + 0.5;
@@ -250,12 +252,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
ctx.stroke(); ctx.stroke();
} }
// hand off to user drawing code, still clipped to plot area
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx }); draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore(); ctx.restore();
// border
ctx.strokeStyle = axisColor; ctx.strokeStyle = axisColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect( ctx.strokeRect(
@@ -265,37 +265,36 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
Math.round(plot.height) - 1 Math.round(plot.height) - 1
); );
// ticks + labels (outside clip)
if (showTicks) { if (showTicks) {
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
for (const gx of range(xMin, xMax, effXStep)) { for (const gx of range(xMin, xMax, effXStep)) {
const px = toPxX(gx); const px = toPxX(gx);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height); ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + 4); ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
ctx.strokeStyle = axisColor; ctx.strokeStyle = axisColor;
ctx.stroke(); ctx.stroke();
ctx.fillText(formatX(gx), px, plot.y + plot.height + 6); ctx.fillText(formatX(gx), px, plot.y + plot.height + metrics.tickLen + 2);
} }
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (const gy of range(yMin, yMax, effYStep)) { for (const gy of range(yMin, yMax, effYStep)) {
const py = toPxY(gy); const py = toPxY(gy);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(plot.x - 4, py); ctx.moveTo(plot.x - metrics.tickLen, py);
ctx.lineTo(plot.x, py); ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor; ctx.strokeStyle = axisColor;
ctx.stroke(); ctx.stroke();
ctx.fillText(formatY(gy), plot.x - 6, py); ctx.fillText(formatY(gy), plot.x - metrics.tickLen - 2, py);
} }
} }
if (xLabel) { if (xLabel) {
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif'; ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4); ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
@@ -303,10 +302,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
if (yLabel) { if (yLabel) {
ctx.save(); ctx.save();
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif'; ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.translate(12, plot.y + plot.height / 2); ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0); ctx.fillText(yLabel, 0, 0);
ctx.restore(); ctx.restore();
@@ -314,6 +313,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
}, [ }, [
size, size,
pad, pad,
metrics,
xMin, xMin,
xMax, xMax,
effXStep, effXStep,
+41 -65
View File
@@ -1,54 +1,55 @@
.gauge-viewport { .gauge-viewport {
container-type: size;
container-name: gauge;
position: relative; position: relative;
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
width: auto; width: 100%;
overflow: hidden;
}
.gauge-container {
--s: min(calc(100cqw / 70), calc(100cqh / var(--min-height-units, 180)));
position: absolute;
bottom: 0;
left: 50%;
width: calc(70 * var(--s));
height: 100%;
max-height: calc(var(--max-height-units, 240) * var(--s));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end;
align-items: center; align-items: center;
transform: translateX(-50%);
box-sizing: border-box; box-sizing: border-box;
} }
.gauge-track { .gauge-track {
position: relative; position: relative;
width: calc(70 * var(--s));
flex: 1 1 auto; flex: 1 1 auto;
max-height: calc(210 * var(--s));
min-height: 0; min-height: 0;
width: 100%;
display: flex; display: flex;
align-items: stretch;
justify-content: center; justify-content: center;
gap: 1.2cqh;
padding-bottom: 1.5cqh;
padding-top: 1.2cqh;
}
.ticks {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: calc(8 * var(--s)); pointer-events: none;
}
.ticks span {
width: 2.8cqh;
height: 0.55cqh;
min-height: 1px;
background: var(--border-hairline);
border-radius: 999px;
} }
.slider { .slider {
position: relative; position: relative;
flex: 0 0 auto;
width: calc(24 * var(--s)); width: 6.5cqh;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
@@ -63,60 +64,35 @@
transform: translateX(-50%); transform: translateX(-50%);
width: calc(2 * var(--s)); width: 0.55cqh;
min-width: 1px;
height: 100%; height: 100%;
background: var(--border-hairline); background: var(--border-hairline);
border-radius: calc(6 * var(--s)); border-radius: 999px;
} }
.thumb { .thumb {
position: absolute; position: absolute;
left: 50%; left: 50%;
width: calc(18 * var(--s)); width: 5cqh;
aspect-ratio: 1; aspect-ratio: 1;
transform: translate(-50%, 50%); transform: translate(-50%, 50%);
border-radius: 50%; border-radius: 50%;
background: var(--bg-panel); background: var(--bg-panel);
border: calc(2 * var(--s)) solid var(--accent-brand); border: 0.55cqh solid var(--accent-brand);
box-shadow: 0 calc(2 * var(--s)) calc(6 * var(--s)) rgba(0, 0, 0, 0.2); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
}
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: calc(10 * var(--s));
height: calc(2 * var(--s));
background: var(--border-hairline);
border-radius: calc(6 * var(--s));
}
.left {
left: calc(8 * var(--s));
}
.right {
right: calc(8 * var(--s));
} }
.value { .value {
flex: 0 0 auto;
width: 100%; width: 100%;
margin-top: calc(4 * var(--s)); margin-top: 0.8cqh;
padding: calc(2 * var(--s)) calc(4 * var(--s)); padding: 0.5cqh 1cqh;
box-sizing: border-box; box-sizing: border-box;
@@ -128,12 +104,12 @@
white-space: nowrap; white-space: nowrap;
color: var(--accent-brand); color: var(--accent-brand);
font-size: calc(12 * var(--s)); font-size: 3.2cqh;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
box-shadow: 0 calc(2 * var(--s)) calc(6 * var(--s)) rgba(0, 0, 0, 0.2); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
border-radius: calc(8 * var(--s)); border-radius: 1.6cqh;
overflow: hidden; overflow: hidden;
} }
@@ -146,8 +122,8 @@
text-align: center; text-align: center;
background: transparent; background: transparent;
border: calc(1 * var(--s)) solid var(--border-hairline); border: 0.3cqh solid var(--border-hairline);
border-radius: calc(6 * var(--s)); border-radius: 1.2cqh;
appearance: none; appearance: none;
} }
+49 -91
View File
@@ -12,32 +12,16 @@ type VerticalSliderProps = {
value: number; value: number;
onChange?: (value: number) => Promise<boolean>; 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; onInput?: (value: number) => void;
unit?: string; unit?: string;
minAspectHeight?: number;
maxAspectHeight?: number;
className?: string; className?: string;
style?: React.CSSProperties; 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 MIN_TICKS = 3;
const MAX_TICKS = 15; const MAX_TICK_SPACING_PX = 16;
function getDecimals(n: number) { function getDecimals(n: number) {
const s = n.toString(); const s = n.toString();
@@ -55,8 +39,6 @@ export default function VerticalSlider({
onChange, onChange,
onInput, onInput,
unit = '', unit = '',
minAspectHeight = 180,
maxAspectHeight = 240,
className, className,
style, style,
}: VerticalSliderProps) { }: VerticalSliderProps) {
@@ -70,7 +52,7 @@ export default function VerticalSlider({
const dragging = useRef(false); const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false); const keyboardEditing = useRef(false);
const [tickCount, setTickCount] = useState(MAX_TICKS); const [tickCount, setTickCount] = useState(MIN_TICKS);
useEffect(() => { useEffect(() => {
if (dragging.current || keyboardEditing.current || editingText !== null) { if (dragging.current || keyboardEditing.current || editingText !== null) {
@@ -85,20 +67,13 @@ export default function VerticalSlider({
if (!el || typeof ResizeObserver === 'undefined') return; if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => { const recompute = () => {
const w = el.clientWidth;
const h = el.clientHeight; const h = el.clientHeight;
if (!w || !h) return; if (!h) return;
// .gauge-track's width is always calc(70 * --s), so this recovers --s in px. const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
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); setTickCount(n);
}; };
recompute(); recompute();
const observer = new ResizeObserver(recompute); const observer = new ResizeObserver(recompute);
@@ -122,17 +97,13 @@ export default function VerticalSlider({
function normalizeValue(input: number) { function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input)); const clamped = Math.min(max, Math.max(min, input));
const currentStep = getStep(clamped); const currentStep = getStep(clamped);
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep; const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6)); return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
} }
function updateValue(input: number) { function updateValue(input: number) {
const next = normalizeValue(input); const next = normalizeValue(input);
dragValue.current = next; dragValue.current = next;
setDisplayValue(next); setDisplayValue(next);
onInput?.(next); onInput?.(next);
@@ -144,11 +115,9 @@ export default function VerticalSlider({
} }
keyboardEditing.current = true; keyboardEditing.current = true;
e.preventDefault(); e.preventDefault();
const base = keyboardValue.current ?? value; const base = keyboardValue.current ?? value;
const currentStep = getStep(base); const currentStep = getStep(base);
let next = base; let next = base;
@@ -162,9 +131,7 @@ export default function VerticalSlider({
} }
next = normalizeValue(next); next = normalizeValue(next);
keyboardValue.current = next; keyboardValue.current = next;
updateValue(next); updateValue(next);
} }
@@ -172,7 +139,6 @@ export default function VerticalSlider({
if (keyboardValue.current == null) return; if (keyboardValue.current == null) return;
const next = keyboardValue.current; const next = keyboardValue.current;
const success = await onChange?.(next); const success = await onChange?.(next);
keyboardEditing.current = false; keyboardEditing.current = false;
@@ -209,7 +175,6 @@ export default function VerticalSlider({
if (!dragging.current) return; if (!dragging.current) return;
const finalValue = dragValue.current; const finalValue = dragValue.current;
const success = await onChange?.(finalValue); const success = await onChange?.(finalValue);
dragging.current = false; dragging.current = false;
@@ -250,63 +215,56 @@ export default function VerticalSlider({
const percent = (displayValue - min) / (max - min); const percent = (displayValue - min) / (max - min);
const containerStyle = {
'--min-height-units': minAspectHeight,
'--max-height-units': maxAspectHeight,
} as React.CSSProperties;
return ( return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}> <div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container" style={containerStyle}> <div className="gauge-track" ref={trackRef}>
<div className="gauge-track" ref={trackRef}> <div className="ticks left">
<div className="ticks left"> {Array.from({ length: tickCount }).map((_, i) => (
{Array.from({ length: tickCount }).map((_, i) => ( <span key={i} />
<span key={i} /> ))}
))}
</div>
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
<div className="ticks right">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
</div> </div>
<div className="value"> <div
<input className="slider"
ref={inputRef} ref={sliderRef}
type="text" tabIndex={0}
inputMode="decimal" onKeyDown={handleKeyDown}
value={editingText ?? displayValue.toFixed(decimals)} onKeyUp={handleKeyUp}
style={{ width: '100%' }} onPointerDown={startDrag}
onFocus={() => setEditingText(value.toFixed(decimals))} onPointerMove={handlePointerMove}
onChange={(e) => setEditingText(e.target.value)} onPointerUp={endDrag}
onBlur={commitEditing} onPointerCancel={endDrag}
onKeyDown={(e) => { >
if (e.key === 'Enter') inputRef.current?.blur(); <div className="track" />
if (e.key === 'Escape') { <div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div> </div>
<div className="ticks right">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value">
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div> </div>
</div> </div>
); );
+94 -4
View File
@@ -4,7 +4,14 @@ import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { DSP, AppPage } from '../types/types'; import { DSP, AppPage } from '../types/types';
import { Channel, InputChannel, defaultDSPState } from '../types/dsp408State'; import {
Channel,
InputChannel,
defaultDSPState,
defaultMeters,
OutputChannel,
Ratio,
} from '../types/dsp408State';
import { import {
createDSP408, createDSP408,
@@ -37,13 +44,21 @@ export type DSP408Device = {
setGain: (channel: Channel, gain: number) => Promise<boolean>; setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
setGate( setGate: (
inputChannel: InputChannel, inputChannel: InputChannel,
threshold: number, threshold: number,
attack: number, attack: number,
hold: number, hold: number,
release: number release: number
): Promise<boolean>; ) => Promise<boolean>;
setCompressor: (
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
threshold: number
) => Promise<boolean>;
}; };
// ============================================================ // ============================================================
@@ -253,7 +268,7 @@ export function useDSP408(
...dsp, ...dsp,
status: 'disconnected', status: 'disconnected',
state: defaultDSPState, state: defaultDSPState,
meters: null, meters: defaultMeters,
pollingPaused: true, pollingPaused: true,
}; };
@@ -620,6 +635,73 @@ export function useDSP408(
}); });
} }
// ==========================================================
// Compressor
// ==========================================================
async function setCompressor(
dsp: DSP,
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
threshold: number
): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_compressor', {
id: dsp.id,
channel: outputChannel,
ratio,
attack,
release,
knee,
threshold,
});
if (!success) {
notify('Failed to change compressor 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,
output_states: {
...currentConfig.output_states,
[outputChannel]: {
...currentConfig.output_states[outputChannel],
compressor: {
ratio,
attack,
release,
knee,
threshold,
},
},
},
},
},
};
});
notify('Compressor updated', 'success', dsp.name);
return true;
} catch (err) {
notify(`Compressor update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ========================================================== // ==========================================================
// Return API // Return API
// ========================================================== // ==========================================================
@@ -640,6 +722,14 @@ export function useDSP408(
hold: number, hold: number,
release: number release: number
) => setGate(dsp, inputChannel, threshold, attack, hold, release), ) => setGate(dsp, inputChannel, threshold, attack, hold, release),
setCompressor: (
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
threshold: number
) => setCompressor(dsp, outputChannel, ratio, attack, release, knee, threshold),
}; };
} }
+1 -1
View File
@@ -15,7 +15,7 @@ function AddDSPForm({
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
type: DSP_TYPES[0].name, type: DSP_TYPES[0].name,
ip: '192.168.1.100', ip: '192.168.49.30',
port: DSP_TYPES[0].defaultPort, port: DSP_TYPES[0].defaultPort,
deviceId: 1, deviceId: 1,
}); });
+8 -2
View File
@@ -2,9 +2,15 @@
position: fixed; position: fixed;
left: var(--sidebar-width-collapsed); left: var(--sidebar-width-collapsed);
right: 0; right: 0;
bottom: 0;
transition: left var(--dur-base) var(--ease-standard);
top: var(--header-height); top: var(--header-height);
bottom: 0;
display: flex;
flex-direction: column;
min-height: 0;
transition: left var(--dur-base) var(--ease-standard);
} }
.app-root:has(.sidebar-rail--expanded) .dsp-content { .app-root:has(.sidebar-rail--expanded) .dsp-content {
+4
View File
@@ -4,6 +4,7 @@ import Sidebar from '../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon } from '../../assets/icons/AudioIcons'; import { InfoIcon, KnobGainIcon } from '../../assets/icons/AudioIcons';
import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection'; import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection';
import GainSection from '../../components/dsp408/GainSection/GainSection'; import GainSection from '../../components/dsp408/GainSection/GainSection';
import CompressorSection from '../../components/dsp408/CompressorSection/CompressorSection';
import { DSP408Device } from '../../hooks/useDSP408'; import { DSP408Device } from '../../hooks/useDSP408';
import './DspPage.css'; import './DspPage.css';
import GateSection from '../../components/dsp408/GateSection/GateSection'; import GateSection from '../../components/dsp408/GateSection/GateSection';
@@ -179,6 +180,9 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) {
case 'gate': case 'gate':
return <GateSection dsp={dsp} set_gate={dsp408.setGate} />; return <GateSection dsp={dsp} set_gate={dsp408.setGate} />;
case 'compressor':
return <CompressorSection dsp={dsp} set_compressor={dsp408.setCompressor} />;
default: default:
return null; return null;
} }
+24 -8
View File
@@ -192,8 +192,14 @@ export type DSPState = {
current_config: DSPConfigState; current_config: DSPConfigState;
}; };
export interface Meter {
rms: number;
peak: number;
clip: boolean;
}
export interface Meters { export interface Meters {
levels: number[]; channels: Meter[];
} }
/* -- Default Value -- */ /* -- Default Value -- */
@@ -261,17 +267,17 @@ const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
peq_chain: createDefaultPEQChain(), peq_chain: createDefaultPEQChain(),
compressor: { compressor: {
threshold: 0, threshold: -90,
ratio: Ratio.Ratio1_1, ratio: Ratio.Ratio1_1,
attack: 0, attack: 1,
release: 0, release: 10,
knee: 0, knee: 0,
}, },
limiter: { limiter: {
threshold: 0, threshold: -90,
attack: 0, attack: 1,
release: 0, release: 10,
}, },
gain: 0, gain: 0,
@@ -304,7 +310,7 @@ export const defaultDSPState: DSPState = {
delay_unit: DelayUnit.Millisecond, delay_unit: DelayUnit.Millisecond,
input_states: { input_states: {
[InputChannel.InA]: createDefaultInputChannelState('In A'), [InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
[InputChannel.InB]: createDefaultInputChannelState('In B'), [InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'), [InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'), [InputChannel.InD]: createDefaultInputChannelState('In D'),
@@ -322,3 +328,13 @@ export const defaultDSPState: DSPState = {
}, },
}, },
}; };
const createDefaultMeter = (): Meter => ({
rms: -60,
peak: -60,
clip: false,
});
export const defaultMeters: Meters = {
channels: Array.from({ length: 12 }, createDefaultMeter),
};
+1 -1
View File
@@ -11,7 +11,7 @@ export type DSP = {
deviceId: number; deviceId: number;
status: DSPStatus; status: DSPStatus;
state: DSPState; state: DSPState;
meters: Meters | null; meters: Meters;
pollingPaused: boolean; pollingPaused: boolean;
}; };
+1 -1
View File
@@ -89,5 +89,5 @@
--sidebar-width-expanded: 200px; --sidebar-width-expanded: 200px;
/* ---------- DSP ---------- */ /* ---------- DSP ---------- */
--dsp-control-heigth: 40vh; --dsp-control-heigth: 40%;
} }