diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 09d0293..cd7ea33 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { + state.with_device_mut(id, |device| { + device + .dsp + .set_compressor( + channel, + ratio, + attack, + release, + knee, + threshold, + ) + .map_err(|e| e.to_string()) + }) +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87db7cf..8791abb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index adf92c6..8b4a903 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,7 +15,7 @@ "title": "thomann-dsp-app", "width": 1200, "height": 800, - "minWidth": 900, + "minWidth": 1000, "minHeight": 600 } ], diff --git a/src/assets/icons/Triangle.tsx b/src/assets/icons/Triangle.tsx new file mode 100644 index 0000000..a7ced3f --- /dev/null +++ b/src/assets/icons/Triangle.tsx @@ -0,0 +1,27 @@ +export function TriangleUpIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function TriangleDownIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/MeterBar/MeterBar.css b/src/components/MeterBar/MeterBar.css new file mode 100644 index 0000000..7fa55d2 --- /dev/null +++ b/src/components/MeterBar/MeterBar.css @@ -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%; +} diff --git a/src/components/MeterBar/MeterBar.tsx b/src/components/MeterBar/MeterBar.tsx new file mode 100644 index 0000000..c384d71 --- /dev/null +++ b/src/components/MeterBar/MeterBar.tsx @@ -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 ( +
+
{label}
+ +
+
+
+
+
+ Limit +
+ +
+
+ Clip +
+ + {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 ( +
+ ); + })} +
+ +
+
+ +
+ + {formatDb(max)} + + {visibleTicks.map((t) => ( + + {formatDb(t)} + + ))} + + {formatDb(min)} + +
+
+ +
+ {Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'} +
+
+ ); +} + +export default MeterBar; diff --git a/src/components/dsp408/CompressorSection/CompressorSection.css b/src/components/dsp408/CompressorSection/CompressorSection.css new file mode 100644 index 0000000..5d36e1d --- /dev/null +++ b/src/components/dsp408/CompressorSection/CompressorSection.css @@ -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; +} diff --git a/src/components/dsp408/CompressorSection/CompressorSection.tsx b/src/components/dsp408/CompressorSection/CompressorSection.tsx new file mode 100644 index 0000000..3b2093a --- /dev/null +++ b/src/components/dsp408/CompressorSection/CompressorSection.tsx @@ -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.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.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.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; + +function ChoiceStepper({ + 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 ( +
+ 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); + } + }} + /> +
+ + +
+
+ ); +} + +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) => { + const next = { ...comp, ...overrides }; + return setCompressor(channel, next.ratio, next.attack, next.release, next.knee, next.threshold); + }; + + return ( +
+
{outputState.name}
+ +
+ {COMPRESSOR_SLIDER_PARAMS.map((param) => ( +
+ onLiveValue(channel, param.key, value)} + onChange={(value) => commit({ [param.key]: value } as Partial)} + style={{ width: '100%', flex: '1 1 auto' }} + /> +
{param.label}
+
+ ))} + +
+
+ RATIO_LABEL[v]} + onChange={(value) => { + onLiveValue(channel, 'ratio', value); + commit({ ratio: value }); + }} + /> +
Ratio
+
+ +
+ `${v} dB`} + onChange={(value) => { + onLiveValue(channel, 'knee', value); + commit({ knee: value }); + }} + /> +
Knee
+
+
+
+
+ ); +} + +// 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(outputs[0]); + const [liveComp, setLiveComp] = useState>>({}); + + 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 ( + <> +
+
+
{activeOutputState.name} - RMS Compressor
+ + 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(); + }} + /> +
+ +
+
+ {outputs.map((output) => { + const outputState = dsp.state.current_config.output_states[output]; + const idx = OUTPUT_METER_INDEX[output]; + + return ( + + ); + })} +
+
+
+ +
+
+ + {outputs.map((output) => ( + + ))} + +
+
+ + ); +} + +export default CompressorSection; diff --git a/src/components/dsp408/GainSection/GainSection.css b/src/components/dsp408/GainSection/GainSection.css index a7d562d..05b809a 100644 --- a/src/components/dsp408/GainSection/GainSection.css +++ b/src/components/dsp408/GainSection/GainSection.css @@ -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 ) 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; } diff --git a/src/components/dsp408/GainSection/GainSection.tsx b/src/components/dsp408/GainSection/GainSection.tsx index d5d2601..90d4dc8 100644 --- a/src/components/dsp408/GainSection/GainSection.tsx +++ b/src/components/dsp408/GainSection/GainSection.tsx @@ -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(null); - const [scale, setScale] = useState(1); - const toggleMute = async () => { await setMute(channel, !muted); }; @@ -45,59 +38,53 @@ 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 ( -
-
+
{title}
+ + setGain(channel, value)} + /> + + - -
+ {muted ? 'Muted' : 'Mute '} + +
); } @@ -113,11 +100,11 @@ function GainSection({ setMute: (channel: Channel, muted: boolean) => Promise; setInverse: (channel: Channel, inverted: boolean) => Promise; }) { - 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,40 +120,63 @@ function GainSection({ return ( <> -
- { - // 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); - const [x1, y1] = toPx(20000, 0); - ctx.beginPath(); - ctx.moveTo(x0, y0); - ctx.lineTo(x1, y1); - ctx.stroke(); - }} - /> +
+
+
Frequency Response
+ { + 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(); + }} + /> +
+ +
+
Phase Response
+ { + 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(); + }} + /> +
- {inputs.map(({ channel, title }) => ( + {inputs.map((channel) => ( .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; +} diff --git a/src/components/dsp408/GateSection/GateSection.tsx b/src/components/dsp408/GateSection/GateSection.tsx index 2249b21..10ec692 100644 --- a/src/components/dsp408/GateSection/GateSection.tsx +++ b/src/components/dsp408/GateSection/GateSection.tsx @@ -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.InA]: 0, + [InputChannel.InB]: 1, + [InputChannel.InC]: 2, + [InputChannel.InD]: 3, +} as const; + type GateParamKey = (typeof GATE_PARAMS)[number]['key']; type GateValues = { @@ -45,62 +50,37 @@ function GatePanel({ setGate: SetGateFn; onLiveValue: (channel: InputChannel, key: GateParamKey, value: number) => void; }) { - const viewportRef = useRef(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 ( -
-
-
{inputState.name}
+
+
{inputState.name}
-
- {GATE_PARAMS.map((param) => ( -
- onLiveValue(channel, param.key, value)} - onChange={(value) => { - return setGate( - channel, - param.key === 'threshold' ? value : gate.threshold, - param.key === 'attack' ? value : gate.attack, - param.key === 'hold' ? value : gate.hold, - param.key === 'release' ? value : gate.release - ); - }} - style={{ width: '100%', flex: '1 1 auto' }} - maxAspectHeight={165} - /> -
{param.label}
-
- ))} -
+
+ {GATE_PARAMS.map((param) => ( +
+ onLiveValue(channel, param.key, value)} + onChange={(value) => { + return setGate( + channel, + param.key === 'threshold' ? value : gate.threshold, + param.key === 'attack' ? value : gate.attack, + param.key === 'hold' ? value : gate.hold, + param.key === 'release' ? value : gate.release + ); + }} + style={{ width: '100%', flex: '1 1 auto' }} + /> +
{param.label}
+
+ ))}
); @@ -109,14 +89,7 @@ function GatePanel({ 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(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>>({}); 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,72 +119,78 @@ 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 ( <> -
-
- Gate envelope - {activeInputState.name} +
+
+
Frequency Response
+ 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(); + }} + />
- Math.round(v).toString()} - formatY={(v) => Math.round(v).toString()} - draw={({ toPx, ctx }) => { - const { threshold, t0, t1, t2, t3, t4, t5 } = envelope; - // Threshold reference line - ctx.strokeStyle = '#fbbf24'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - const [tx0, ty] = toPx(t0, threshold); - const [tx1] = toPx(t5, threshold); - ctx.beginPath(); - ctx.moveTo(tx0, ty); - ctx.lineTo(tx1, ty); - ctx.stroke(); - ctx.setLineDash([]); - - // Gain envelope: closed -> attack -> hold -> release -> closed - ctx.strokeStyle = '#4ade80'; - ctx.lineWidth = 2; - ctx.lineJoin = 'round'; - const points: [number, number][] = [ - [t0, -90], - [t1, -90], - [t2, 0], - [t3, 0], - [t4, -90], - [t5, -90], - ]; - ctx.beginPath(); - points.forEach(([t, g], i) => { - const [px, py] = toPx(t, g); - if (i === 0) ctx.moveTo(px, py); - else ctx.lineTo(px, py); - }); - ctx.stroke(); - }} - /> +
+
+ {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 ; + })} +
+
diff --git a/src/components/dsp408/LinearGraph/LinearGraph.css b/src/components/dsp408/LinearGraph/LinearGraph.css deleted file mode 100644 index 95322ef..0000000 --- a/src/components/dsp408/LinearGraph/LinearGraph.css +++ /dev/null @@ -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 ) 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 */ -} diff --git a/src/components/dsp408/LinearGraph/LinearGraph.tsx b/src/components/dsp408/LinearGraph/LinearGraph.tsx index c0d0a4a..94756d5 100644 --- a/src/components/dsp408/LinearGraph/LinearGraph.tsx +++ b/src/components/dsp408/LinearGraph/LinearGraph.tsx @@ -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(function LinearGraph( { xMin, @@ -122,20 +101,45 @@ export const LinearGraph = forwardRef(funct const canvasRef = useRef(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(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(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(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(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(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(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(funct }, [ size, pad, + metrics, xMin, xMax, effXStep, diff --git a/src/components/dsp408/Slider/VerticalSlider.css b/src/components/dsp408/Slider/VerticalSlider.css index 320ca81..a3e70cf 100644 --- a/src/components/dsp408/Slider/VerticalSlider.css +++ b/src/components/dsp408/Slider/VerticalSlider.css @@ -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; } diff --git a/src/components/dsp408/Slider/VerticalSlider.tsx b/src/components/dsp408/Slider/VerticalSlider.tsx index 527e4b1..7a01e27 100644 --- a/src/components/dsp408/Slider/VerticalSlider.tsx +++ b/src/components/dsp408/Slider/VerticalSlider.tsx @@ -12,32 +12,16 @@ type VerticalSliderProps = { value: number; onChange?: (value: number) => Promise; - /** - * 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(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,63 +215,56 @@ export default function VerticalSlider({ const percent = (displayValue - min) / (max - min); - const containerStyle = { - '--min-height-units': minAspectHeight, - '--max-height-units': maxAspectHeight, - } as React.CSSProperties; - return (
-
-
-
- {Array.from({ length: tickCount }).map((_, i) => ( - - ))} -
- -
-
-
-
- -
- {Array.from({ length: tickCount }).map((_, i) => ( - - ))} -
+
+
+ {Array.from({ length: tickCount }).map((_, i) => ( + + ))}
-
- 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(); - } - }} - /> - {unit} +
+
+
+ +
+ {Array.from({ length: tickCount }).map((_, i) => ( + + ))} +
+
+ +
+ 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(); + } + }} + /> + {unit}
); diff --git a/src/hooks/useDSP408.ts b/src/hooks/useDSP408.ts index 2bc9476..aa13fc9 100644 --- a/src/hooks/useDSP408.ts +++ b/src/hooks/useDSP408.ts @@ -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; setMute: (channel: Channel, muted: boolean) => Promise; setInverse: (channel: Channel, inverted: boolean) => Promise; - setGate( + setGate: ( inputChannel: InputChannel, threshold: number, attack: number, hold: number, release: number - ): Promise; + ) => Promise; + setCompressor: ( + outputChannel: OutputChannel, + ratio: Ratio, + attack: number, + release: number, + knee: number, + threshold: number + ) => Promise; }; // ============================================================ @@ -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 { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('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), }; } diff --git a/src/pages/AddDspPage/AddDspPage.tsx b/src/pages/AddDspPage/AddDspPage.tsx index dbb24d2..bc4a0ba 100644 --- a/src/pages/AddDspPage/AddDspPage.tsx +++ b/src/pages/AddDspPage/AddDspPage.tsx @@ -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, }); diff --git a/src/pages/DspPage/DspPage.css b/src/pages/DspPage/DspPage.css index d5bab92..cf69751 100644 --- a/src/pages/DspPage/DspPage.css +++ b/src/pages/DspPage/DspPage.css @@ -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 { diff --git a/src/pages/DspPage/DspPage.tsx b/src/pages/DspPage/DspPage.tsx index 86b8c35..7a57dec 100644 --- a/src/pages/DspPage/DspPage.tsx +++ b/src/pages/DspPage/DspPage.tsx @@ -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 ; + case 'compressor': + return ; + default: return null; } diff --git a/src/types/dsp408State.ts b/src/types/dsp408State.ts index cf5e5f9..2a7f130 100644 --- a/src/types/dsp408State.ts +++ b/src/types/dsp408State.ts @@ -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), +}; diff --git a/src/types/types.ts b/src/types/types.ts index dd84c77..bca7b42 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -11,7 +11,7 @@ export type DSP = { deviceId: number; status: DSPStatus; state: DSPState; - meters: Meters | null; + meters: Meters; pollingPaused: boolean; }; diff --git a/src/variables.css b/src/variables.css index 9274355..9a3356b 100644 --- a/src/variables.css +++ b/src/variables.css @@ -89,5 +89,5 @@ --sidebar-width-expanded: 200px; /* ---------- DSP ---------- */ - --dsp-control-heigth: 40vh; + --dsp-control-heigth: 40%; }