feat: add compressor + fix ui for all sections
This commit is contained in:
@@ -265,3 +265,29 @@ pub async fn set_gate(
|
||||
.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())
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub fn run() {
|
||||
commands::get_meter_levels,
|
||||
commands::set_current_preset_name,
|
||||
commands::set_gate,
|
||||
commands::set_compressor,
|
||||
]
|
||||
)
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"title": "thomann-dsp-app",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minWidth": 1000,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 {
|
||||
container-type: size;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -67,20 +28,78 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* --- 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);
|
||||
.gain-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 4;
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
gap: 2cqh;
|
||||
background: var(--bg-void);
|
||||
padding-bottom: 2cqh;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: var(--space-4);
|
||||
|
||||
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 ChannelGroup from '../ChannelGroup/ChannelGroup';
|
||||
import { LinearGraph } from '../LinearGraph/LinearGraph';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './GainSection.css';
|
||||
|
||||
const PANEL_DESIGN_WIDTH = 90;
|
||||
const PANEL_DESIGN_HEIGHT = 400;
|
||||
|
||||
function GainPanel({
|
||||
dsp,
|
||||
channel,
|
||||
@@ -34,9 +30,6 @@ function GainPanel({
|
||||
const inverted = channelState?.phase_inverted ?? false;
|
||||
const gain = channelState?.gain ?? 0;
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const toggleMute = async () => {
|
||||
await setMute(channel, !muted);
|
||||
};
|
||||
@@ -45,28 +38,8 @@ function GainPanel({
|
||||
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 (
|
||||
<div className="gain-panel-viewport" ref={viewportRef}>
|
||||
<div
|
||||
className="gain-panel"
|
||||
style={{
|
||||
width: PANEL_DESIGN_WIDTH,
|
||||
height: PANEL_DESIGN_HEIGHT,
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div className="gain-panel">
|
||||
<div className="gain-panel__title">{title}</div>
|
||||
|
||||
<VerticalSlider
|
||||
@@ -78,14 +51,21 @@ function GainPanel({
|
||||
unit=" dB"
|
||||
value={gain}
|
||||
onChange={(value) => setGain(channel, value)}
|
||||
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 }}
|
||||
style={{
|
||||
width: '85%',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
fontSize: '4cqh',
|
||||
paddingTop: '2cqh',
|
||||
paddingBottom: '2cqh',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{muted ? 'Muted' : 'Mute '}
|
||||
</Button>
|
||||
@@ -93,12 +73,19 @@ function GainPanel({
|
||||
variant="toggle"
|
||||
className={inverted ? 'active-warning' : ''}
|
||||
onClick={toggleInverse}
|
||||
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
|
||||
style={{
|
||||
width: '85%',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
fontSize: '4cqh',
|
||||
paddingTop: '2cqh',
|
||||
paddingBottom: '2cqh',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{inverted ? 'Inverted' : 'Invert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,11 +100,11 @@ function GainSection({
|
||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
||||
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
|
||||
}) {
|
||||
const inputs: { channel: Channel; title: string }[] = [
|
||||
{ channel: { Input: InputChannel.InA }, title: 'In A' },
|
||||
{ channel: { Input: InputChannel.InB }, title: 'In B' },
|
||||
{ channel: { Input: InputChannel.InC }, title: 'In C' },
|
||||
{ channel: { Input: InputChannel.InD }, title: 'In D' },
|
||||
const inputs: InputChannel[] = [
|
||||
InputChannel.InA,
|
||||
InputChannel.InB,
|
||||
InputChannel.InC,
|
||||
InputChannel.InD,
|
||||
];
|
||||
|
||||
const outputs: { channel: Channel; title: string }[] = [
|
||||
@@ -133,7 +120,9 @@ function GainSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="linear-graph-section">
|
||||
<div className="gain-linear-graph-section">
|
||||
<div className="gain-graph-panel">
|
||||
<div className="gain-graph-title">Frequency Response</div>
|
||||
<LinearGraph
|
||||
xMin={20}
|
||||
xMax={20000}
|
||||
@@ -143,9 +132,6 @@ function GainSection({
|
||||
xLabel="Hz"
|
||||
yLabel="dB"
|
||||
draw={({ ctx, toPx }) => {
|
||||
// placeholder flat 0 dB response — swap for real EQ/filter data.
|
||||
// NOTE: this maps x linearly; for a real frequency response you'll
|
||||
// likely want a log-frequency x-axis instead (see chat notes).
|
||||
ctx.strokeStyle = '#4ade80';
|
||||
ctx.lineWidth = 2;
|
||||
const [x0, y0] = toPx(20, 0);
|
||||
@@ -158,15 +144,39 @@ function GainSection({
|
||||
/>
|
||||
</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 className="gain-section">
|
||||
<div className="gain-panels">
|
||||
<ChannelGroup title="Input">
|
||||
{inputs.map(({ channel, title }) => (
|
||||
{inputs.map((channel) => (
|
||||
<GainPanel
|
||||
key={title}
|
||||
key={channel}
|
||||
dsp={dsp}
|
||||
channel={channel}
|
||||
title={title}
|
||||
channel={{ Input: channel }}
|
||||
title={dsp.state.current_config.input_states[channel].name}
|
||||
setGain={setGain}
|
||||
setMute={setMute}
|
||||
setInverse={setInverse}
|
||||
|
||||
@@ -1,110 +1,13 @@
|
||||
/* 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);
|
||||
height: 40%;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -125,3 +28,132 @@
|
||||
gap: var(--space-2);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import { InputChannel } from '../../../types/dsp408State';
|
||||
import { DSP } from '../../../types/types';
|
||||
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
||||
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';
|
||||
|
||||
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' },
|
||||
@@ -17,6 +15,13 @@ const GATE_PARAMS = [
|
||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||
] 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 GateValues = {
|
||||
@@ -45,34 +50,11 @@ function GatePanel({
|
||||
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="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">
|
||||
<div className="gate-panel__title">{inputState.name}</div>
|
||||
|
||||
<div className="gate-panel__grid">
|
||||
@@ -95,28 +77,19 @@ function GatePanel({
|
||||
);
|
||||
}}
|
||||
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) => {
|
||||
@@ -132,8 +105,6 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
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 };
|
||||
@@ -148,27 +119,23 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
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 t1 = t0 + roll;
|
||||
const t2 = t1 + attack;
|
||||
const t3 = t2 + hold;
|
||||
const t4 = t3 + release;
|
||||
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>
|
||||
<div className="gate-linear-graph-section">
|
||||
<div className="gate-graph-panel">
|
||||
<div className="gate-graph-title">Frequency Response</div>
|
||||
<LinearGraph
|
||||
xMin={envelope.t0}
|
||||
xMax={envelope.t5}
|
||||
@@ -181,7 +148,6 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
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]);
|
||||
@@ -193,7 +159,6 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Gain envelope: closed -> attack -> hold -> release -> closed
|
||||
ctx.strokeStyle = '#4ade80';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
@@ -216,6 +181,18 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gate-meter-panel">
|
||||
<div className="gate-graph__meters">
|
||||
{inputs.map((input) => {
|
||||
const inputState = dsp.state.current_config.input_states[input];
|
||||
const idx = INPUT_METER_INDEX[input];
|
||||
const meter = dsp.meters.channels[idx];
|
||||
return <MeterBar label={inputState.name} meter={meter} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gate-section">
|
||||
<div className="gate-panels">
|
||||
<ChannelGroup title="Input">
|
||||
|
||||
@@ -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,
|
||||
useState,
|
||||
} 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 {
|
||||
/** data (x, y) -> canvas pixel (px, py) */
|
||||
toPx: (x: number, y: number) => [number, number];
|
||||
/** data x -> pixel x */
|
||||
toPxX: (x: number) => number;
|
||||
/** data y -> pixel y */
|
||||
toPxY: (y: number) => number;
|
||||
/** pixel (px, py) -> data (x, y). Useful for handling clicks/drags. */
|
||||
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 };
|
||||
ctx: CanvasRenderingContext2D;
|
||||
}
|
||||
@@ -31,38 +20,28 @@ export interface LinearGraphHelpers {
|
||||
export type DrawFn = (helpers: LinearGraphHelpers) => void;
|
||||
|
||||
export interface LinearGraphHandle {
|
||||
/** Force a redraw (e.g. after mutating a ref-held buffer without a re-render) */
|
||||
redraw: () => void;
|
||||
/** Access the raw canvas, e.g. for exporting a PNG */
|
||||
getCanvas: () => HTMLCanvasElement | null;
|
||||
}
|
||||
|
||||
export interface LinearGraphProps {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
/** grid + tick spacing on x. Omit to auto-pick a "nice" step. */
|
||||
xStep?: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
yStep?: number;
|
||||
|
||||
/** Your drawing code. Called every time the graph needs to repaint. */
|
||||
draw?: DrawFn;
|
||||
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** padding (px) reserved for axis labels, in CSS pixels */
|
||||
padding?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||
|
||||
showGrid?: boolean;
|
||||
showAxes?: boolean;
|
||||
showTicks?: boolean;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
/** format a tick value, e.g. (v) => v.toFixed(1) or Hz->kHz */
|
||||
formatX?: (v: number) => string;
|
||||
formatY?: (v: number) => string;
|
||||
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
@@ -74,7 +53,6 @@ const defaultFormat = (v: number) => {
|
||||
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 {
|
||||
const range = Math.abs(max - min) || 1;
|
||||
const raw = range / targetTicks;
|
||||
@@ -89,12 +67,13 @@ function range(min: number, max: number, step: number): number[] {
|
||||
const out: number[] = [];
|
||||
const start = Math.ceil((min - 1e-9) / step) * 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);
|
||||
}
|
||||
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(
|
||||
{
|
||||
xMin,
|
||||
@@ -122,20 +101,45 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
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,
|
||||
right: padding?.right ?? 16,
|
||||
bottom: padding?.bottom ?? (xLabel ? 44 : 28),
|
||||
left: padding?.left ?? (yLabel ? 56 : 44),
|
||||
tickFont: cq.min * 3,
|
||||
labelFont: cq.min * 4,
|
||||
tickLen: cq.min * 1.2,
|
||||
axisLineWidth: cq.min * 0.4,
|
||||
yLabelOffset: cq.w * 2,
|
||||
}),
|
||||
[padding, xLabel, yLabel]
|
||||
[cq]
|
||||
);
|
||||
|
||||
const effXStep = xStep ?? niceStep(xMin, xMax);
|
||||
const effYStep = yStep ?? niceStep(yMin, yMax);
|
||||
|
||||
// Responsive sizing when width/height aren't fixed
|
||||
useEffect(() => {
|
||||
if (width && height) return;
|
||||
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);
|
||||
}
|
||||
|
||||
// clip to plot area so user drawing can't bleed into labels
|
||||
const clipPad = 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -206,7 +209,6 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
);
|
||||
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(
|
||||
@@ -235,7 +237,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
|
||||
if (showAxes) {
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineWidth = metrics.axisLineWidth;
|
||||
ctx.beginPath();
|
||||
if (yMin <= 0 && yMax >= 0) {
|
||||
const py = Math.round(toPxY(0)) + 0.5;
|
||||
@@ -250,12 +252,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// hand off to user drawing code, still clipped to plot area
|
||||
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// border
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(
|
||||
@@ -265,37 +265,36 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
Math.round(plot.height) - 1
|
||||
);
|
||||
|
||||
// ticks + labels (outside clip)
|
||||
if (showTicks) {
|
||||
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.textBaseline = 'top';
|
||||
for (const gx of range(xMin, xMax, effXStep)) {
|
||||
const px = toPxX(gx);
|
||||
ctx.beginPath();
|
||||
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.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.textBaseline = 'middle';
|
||||
for (const gy of range(yMin, yMax, effYStep)) {
|
||||
const py = toPxY(gy);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(plot.x - 4, py);
|
||||
ctx.moveTo(plot.x - metrics.tickLen, py);
|
||||
ctx.lineTo(plot.x, py);
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.stroke();
|
||||
ctx.fillText(formatY(gy), plot.x - 6, py);
|
||||
ctx.fillText(formatY(gy), plot.x - metrics.tickLen - 2, py);
|
||||
}
|
||||
}
|
||||
|
||||
if (xLabel) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
|
||||
@@ -303,10 +302,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
if (yLabel) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
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.fillText(yLabel, 0, 0);
|
||||
ctx.restore();
|
||||
@@ -314,6 +313,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
}, [
|
||||
size,
|
||||
pad,
|
||||
metrics,
|
||||
xMin,
|
||||
xMax,
|
||||
effXStep,
|
||||
|
||||
@@ -1,54 +1,55 @@
|
||||
.gauge-viewport {
|
||||
container-type: size;
|
||||
container-name: gauge;
|
||||
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: auto;
|
||||
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));
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
transform: translateX(-50%);
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gauge-track {
|
||||
position: relative;
|
||||
|
||||
width: calc(70 * var(--s));
|
||||
flex: 1 1 auto;
|
||||
max-height: calc(210 * var(--s));
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
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;
|
||||
|
||||
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 {
|
||||
position: relative;
|
||||
|
||||
width: calc(24 * var(--s));
|
||||
flex: 0 0 auto;
|
||||
width: 6.5cqh;
|
||||
height: 100%;
|
||||
|
||||
cursor: pointer;
|
||||
@@ -63,60 +64,35 @@
|
||||
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: calc(2 * var(--s));
|
||||
width: 0.55cqh;
|
||||
min-width: 1px;
|
||||
height: 100%;
|
||||
|
||||
background: var(--border-hairline);
|
||||
border-radius: calc(6 * var(--s));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
width: calc(18 * var(--s));
|
||||
width: 5cqh;
|
||||
aspect-ratio: 1;
|
||||
|
||||
transform: translate(-50%, 50%);
|
||||
|
||||
border-radius: 50%;
|
||||
background: var(--bg-panel);
|
||||
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 {
|
||||
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));
|
||||
border: 0.55cqh solid var(--accent-brand);
|
||||
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
|
||||
margin-top: calc(4 * var(--s));
|
||||
padding: calc(2 * var(--s)) calc(4 * var(--s));
|
||||
margin-top: 0.8cqh;
|
||||
padding: 0.5cqh 1cqh;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -128,12 +104,12 @@
|
||||
white-space: nowrap;
|
||||
|
||||
color: var(--accent-brand);
|
||||
font-size: calc(12 * var(--s));
|
||||
font-size: 3.2cqh;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
box-shadow: 0 calc(2 * var(--s)) calc(6 * var(--s)) rgba(0, 0, 0, 0.2);
|
||||
border-radius: calc(8 * var(--s));
|
||||
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
|
||||
border-radius: 1.6cqh;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -146,8 +122,8 @@
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
|
||||
border: calc(1 * var(--s)) solid var(--border-hairline);
|
||||
border-radius: calc(6 * var(--s));
|
||||
border: 0.3cqh solid var(--border-hairline);
|
||||
border-radius: 1.2cqh;
|
||||
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
@@ -12,32 +12,16 @@ 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;
|
||||
const MAX_TICK_SPACING_PX = 16;
|
||||
|
||||
function getDecimals(n: number) {
|
||||
const s = n.toString();
|
||||
@@ -55,8 +39,6 @@ export default function VerticalSlider({
|
||||
onChange,
|
||||
onInput,
|
||||
unit = '',
|
||||
minAspectHeight = 180,
|
||||
maxAspectHeight = 240,
|
||||
className,
|
||||
style,
|
||||
}: VerticalSliderProps) {
|
||||
@@ -70,7 +52,7 @@ export default function VerticalSlider({
|
||||
const dragging = useRef(false);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardEditing = useRef(false);
|
||||
const [tickCount, setTickCount] = useState(MAX_TICKS);
|
||||
const [tickCount, setTickCount] = useState(MIN_TICKS);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging.current || keyboardEditing.current || editingText !== null) {
|
||||
@@ -85,20 +67,13 @@ export default function VerticalSlider({
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const recompute = () => {
|
||||
const w = el.clientWidth;
|
||||
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 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)));
|
||||
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
|
||||
|
||||
setTickCount(n);
|
||||
};
|
||||
|
||||
recompute();
|
||||
|
||||
const observer = new ResizeObserver(recompute);
|
||||
@@ -122,17 +97,13 @@ export default function VerticalSlider({
|
||||
|
||||
function normalizeValue(input: number) {
|
||||
const clamped = Math.min(max, Math.max(min, input));
|
||||
|
||||
const currentStep = getStep(clamped);
|
||||
|
||||
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
|
||||
|
||||
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
|
||||
}
|
||||
|
||||
function updateValue(input: number) {
|
||||
const next = normalizeValue(input);
|
||||
|
||||
dragValue.current = next;
|
||||
setDisplayValue(next);
|
||||
onInput?.(next);
|
||||
@@ -144,11 +115,9 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
keyboardEditing.current = true;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const base = keyboardValue.current ?? value;
|
||||
|
||||
const currentStep = getStep(base);
|
||||
|
||||
let next = base;
|
||||
@@ -162,9 +131,7 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
next = normalizeValue(next);
|
||||
|
||||
keyboardValue.current = next;
|
||||
|
||||
updateValue(next);
|
||||
}
|
||||
|
||||
@@ -172,7 +139,6 @@ export default function VerticalSlider({
|
||||
if (keyboardValue.current == null) return;
|
||||
|
||||
const next = keyboardValue.current;
|
||||
|
||||
const success = await onChange?.(next);
|
||||
|
||||
keyboardEditing.current = false;
|
||||
@@ -209,7 +175,6 @@ export default function VerticalSlider({
|
||||
if (!dragging.current) return;
|
||||
|
||||
const finalValue = dragValue.current;
|
||||
|
||||
const success = await onChange?.(finalValue);
|
||||
|
||||
dragging.current = false;
|
||||
@@ -250,14 +215,8 @@ 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" style={containerStyle}>
|
||||
<div className="gauge-track" ref={trackRef}>
|
||||
<div className="ticks left">
|
||||
{Array.from({ length: tickCount }).map((_, i) => (
|
||||
@@ -308,6 +267,5 @@ export default function VerticalSlider({
|
||||
<span className="value-unit">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+94
-4
@@ -4,7 +4,14 @@ import { useEffect, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { DSP, AppPage } from '../types/types';
|
||||
import { Channel, InputChannel, defaultDSPState } from '../types/dsp408State';
|
||||
import {
|
||||
Channel,
|
||||
InputChannel,
|
||||
defaultDSPState,
|
||||
defaultMeters,
|
||||
OutputChannel,
|
||||
Ratio,
|
||||
} from '../types/dsp408State';
|
||||
|
||||
import {
|
||||
createDSP408,
|
||||
@@ -37,13 +44,21 @@ 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(
|
||||
setGate: (
|
||||
inputChannel: InputChannel,
|
||||
threshold: number,
|
||||
attack: number,
|
||||
hold: 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,
|
||||
status: 'disconnected',
|
||||
state: defaultDSPState,
|
||||
meters: null,
|
||||
meters: defaultMeters,
|
||||
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
|
||||
// ==========================================================
|
||||
@@ -640,6 +722,14 @@ export function useDSP408(
|
||||
hold: number,
|
||||
release: number
|
||||
) => 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function AddDSPForm({
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
type: DSP_TYPES[0].name,
|
||||
ip: '192.168.1.100',
|
||||
ip: '192.168.49.30',
|
||||
port: DSP_TYPES[0].defaultPort,
|
||||
deviceId: 1,
|
||||
});
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
position: fixed;
|
||||
left: var(--sidebar-width-collapsed);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transition: left var(--dur-base) var(--ease-standard);
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Sidebar from '../../components/Sidebar/Sidebar';
|
||||
import { InfoIcon, KnobGainIcon } from '../../assets/icons/AudioIcons';
|
||||
import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection';
|
||||
import GainSection from '../../components/dsp408/GainSection/GainSection';
|
||||
import CompressorSection from '../../components/dsp408/CompressorSection/CompressorSection';
|
||||
import { DSP408Device } from '../../hooks/useDSP408';
|
||||
import './DspPage.css';
|
||||
import GateSection from '../../components/dsp408/GateSection/GateSection';
|
||||
@@ -179,6 +180,9 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) {
|
||||
case 'gate':
|
||||
return <GateSection dsp={dsp} set_gate={dsp408.setGate} />;
|
||||
|
||||
case 'compressor':
|
||||
return <CompressorSection dsp={dsp} set_compressor={dsp408.setCompressor} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,8 +192,14 @@ export type DSPState = {
|
||||
current_config: DSPConfigState;
|
||||
};
|
||||
|
||||
export interface Meter {
|
||||
rms: number;
|
||||
peak: number;
|
||||
clip: boolean;
|
||||
}
|
||||
|
||||
export interface Meters {
|
||||
levels: number[];
|
||||
channels: Meter[];
|
||||
}
|
||||
|
||||
/* -- Default Value -- */
|
||||
@@ -261,17 +267,17 @@ const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
|
||||
peq_chain: createDefaultPEQChain(),
|
||||
|
||||
compressor: {
|
||||
threshold: 0,
|
||||
threshold: -90,
|
||||
ratio: Ratio.Ratio1_1,
|
||||
attack: 0,
|
||||
release: 0,
|
||||
attack: 1,
|
||||
release: 10,
|
||||
knee: 0,
|
||||
},
|
||||
|
||||
limiter: {
|
||||
threshold: 0,
|
||||
attack: 0,
|
||||
release: 0,
|
||||
threshold: -90,
|
||||
attack: 1,
|
||||
release: 10,
|
||||
},
|
||||
|
||||
gain: 0,
|
||||
@@ -304,7 +310,7 @@ export const defaultDSPState: DSPState = {
|
||||
delay_unit: DelayUnit.Millisecond,
|
||||
|
||||
input_states: {
|
||||
[InputChannel.InA]: createDefaultInputChannelState('In A'),
|
||||
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
|
||||
[InputChannel.InB]: createDefaultInputChannelState('In B'),
|
||||
[InputChannel.InC]: createDefaultInputChannelState('In C'),
|
||||
[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
@@ -11,7 +11,7 @@ export type DSP = {
|
||||
deviceId: number;
|
||||
status: DSPStatus;
|
||||
state: DSPState;
|
||||
meters: Meters | null;
|
||||
meters: Meters;
|
||||
pollingPaused: boolean;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -89,5 +89,5 @@
|
||||
--sidebar-width-expanded: 200px;
|
||||
|
||||
/* ---------- DSP ---------- */
|
||||
--dsp-control-heigth: 40vh;
|
||||
--dsp-control-heigth: 40%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user