diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cd7ea33..4538d1c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -290,4 +290,21 @@ pub async fn set_compressor( ) .map_err(|e| e.to_string()) }) +} + +#[tauri::command] +pub async fn set_limiter( + state: State<'_, AppState>, + id: u64, + channel: types::OutputChannel, + threshold: f32, + attack: u16, + release: u16, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .set_limiter(channel, threshold, attack, release) + .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 8791abb..cabe86c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { commands::set_current_preset_name, commands::set_gate, commands::set_compressor, + commands::set_limiter, ] ) .run(tauri::generate_context!()) diff --git a/src/components/dsp408/LimiterSection/LimiterSection.css b/src/components/dsp408/LimiterSection/LimiterSection.css new file mode 100644 index 0000000..b17c6f4 --- /dev/null +++ b/src/components/dsp408/LimiterSection/LimiterSection.css @@ -0,0 +1,171 @@ +.limiter-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); +} + +.limiter-panels { + height: 100%; + display: flex; + gap: var(--space-2); + overflow-x: auto; +} + +.limiter-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); +} + +.limiter-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; +} + +.limiter-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' + 'threshold release'; + gap: 2cqh; + + padding: 2cqh; + box-sizing: border-box; +} + +.limiter-panel__cell--threshold { + grid-area: threshold; +} +.limiter-panel__cell--attack { + grid-area: attack; +} +.limiter-panel__cell--release { + grid-area: release; +} + +.limiter-panel__cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 0; + min-width: 0; + gap: 1cqh; +} + +.limiter-panel__cell-label { + flex: 0 0 auto; + font-family: var(--font-ui); + font-size: 2.5cqh; + color: var(--text-muted); + white-space: nowrap; +} + +.limiter-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); +} + +.limiter-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); +} + +.limiter-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); +} + +.limiter-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); +} + +.limiter-graph__meters { + flex: 1; + min-height: 0; + + display: flex; + flex-direction: row; + align-items: stretch; + + gap: 1cqh; + padding: 1cqh; +} + +.limiter-graph-panel > .container { + flex: 1; + min-height: 0; +} diff --git a/src/components/dsp408/LimiterSection/LimiterSection.tsx b/src/components/dsp408/LimiterSection/LimiterSection.tsx new file mode 100644 index 0000000..2d51252 --- /dev/null +++ b/src/components/dsp408/LimiterSection/LimiterSection.tsx @@ -0,0 +1,238 @@ +// LimiterSection.tsx +import VerticalSlider from '../Slider/VerticalSlider'; +import { OutputChannel } from '../../../types/dsp408State'; +import { DSP } from '../../../types/types'; +import ChannelGroup from '../ChannelGroup/ChannelGroup'; +import { LinearGraph } from '../LinearGraph/LinearGraph'; +import MeterBar from '../../MeterBar/MeterBar'; +import { useMemo, useState } from 'react'; +import './LimiterSection.css'; + +const LIMITER_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. Same mapping as CompressorSection — adjust if +// dsp.meters.channels ordering differs. +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 LimiterParamKey = 'threshold' | 'attack' | 'release'; + +type LimiterValues = { + threshold: number; + attack: number; + release: number; +}; + +type SetLimiterFn = ( + outputChannel: OutputChannel, + threshold: number, + attack: number, + release: number +) => Promise; + +function LimiterPanel({ + channel, + dsp, + setLimiter, + onLiveValue, +}: { + channel: OutputChannel; + dsp: DSP; + setLimiter: SetLimiterFn; + onLiveValue: (channel: OutputChannel, key: LimiterParamKey, value: number) => void; +}) { + const outputState = dsp.state.current_config.output_states[channel]; + // ASSUMPTION: limiter settings live at output_states[channel].limiter + const lim = outputState.limiter as LimiterValues; + + const commit = (overrides: Partial) => { + const next = { ...lim, ...overrides }; + return setLimiter(channel, next.threshold, next.attack, next.release); + }; + + return ( +
+
{outputState.name}
+ +
+ {LIMITER_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}
+
+ ))} +
+
+ ); +} + +// Hard-knee brickwall limiter transfer curve, in dB. +function limiterOutputDb(input: number, threshold: number): number { + return input <= threshold ? input : threshold; +} + +function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimiterFn }) { + 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 [liveLim, setLiveLim] = useState>>({}); + + const handleLiveValue = (channel: OutputChannel, key: LimiterParamKey, value: number) => { + setActiveChannel(channel); + setLiveLim((prev) => { + const base = + prev[channel] ?? (dsp.state.current_config.output_states[channel].limiter as LimiterValues); + return { ...prev, [channel]: { ...base, [key]: value } }; + }); + }; + + const handleSetLimiter: SetLimiterFn = async (channel, threshold, attack, release) => { + setActiveChannel(channel); + const success = await set_limiter(channel, threshold, attack, release); + + if (success) { + setLiveLim((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 activeLim = liveLim[activeChannel] ?? (activeOutputState.limiter as LimiterValues); + + const curvePoints = useMemo(() => { + const { threshold } = activeLim; + const steps = 120; + const points: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const x = -60 + (60 * i) / steps; + points.push([x, limiterOutputDb(x, threshold)]); + } + return points; + }, [activeLim.threshold]); + + return ( + <> +
+
+
{activeOutputState.name} - Limiter
+ + 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(activeLim.threshold, -60); + const [tx1, ty1] = toPx(activeLim.threshold, 0); + ctx.beginPath(); + ctx.moveTo(tx0, ty0); + ctx.lineTo(tx1, ty1); + ctx.stroke(); + ctx.setLineDash([]); + + // Limiter curve + ctx.strokeStyle = '#f87171'; + 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 LimiterSection; diff --git a/src/components/dsp408/LinearGraph/LinearGraph.tsx b/src/components/dsp408/LinearGraph/LinearGraph.tsx index f589419..427cd25 100644 --- a/src/components/dsp408/LinearGraph/LinearGraph.tsx +++ b/src/components/dsp408/LinearGraph/LinearGraph.tsx @@ -170,9 +170,9 @@ export const LinearGraph = forwardRef(funct ctx.clearRect(0, 0, cssW, cssH); const plot = { - x: pad.left, + x: pad.left + cq.w * 2, y: pad.top, - width: Math.max(1, cssW - pad.left - pad.right), + width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2), height: Math.max(1, cssH - pad.top - pad.bottom), }; diff --git a/src/hooks/useDSP408.ts b/src/hooks/useDSP408.ts index aa13fc9..cb44596 100644 --- a/src/hooks/useDSP408.ts +++ b/src/hooks/useDSP408.ts @@ -59,6 +59,12 @@ export type DSP408Device = { knee: number, threshold: number ) => Promise; + setLimiter: ( + outputChannel: OutputChannel, + threshold: number, + attack: number, + release: number + ) => Promise; }; // ============================================================ @@ -702,6 +708,67 @@ export function useDSP408( }); } + // ========================================================== + // Limiter + // ========================================================== + async function setLimiter( + dsp: DSP, + outputChannel: OutputChannel, + threshold: number, + attack: number, + release: number + ): Promise { + return withPollingPaused(dsp, async () => { + try { + const success = await invoke('set_limiter', { + id: dsp.id, + channel: outputChannel, + threshold, + attack, + release, + }); + + if (!success) { + notify('Failed to change limiter 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], + limiter: { + threshold, + attack, + release, + }, + }, + }, + }, + }, + }; + }); + + notify('Limiter updated', 'success', dsp.name); + return true; + } catch (err) { + notify(`Limiter update failed: ${String(err)}`, 'error', dsp.name); + return false; + } + }); + } + // ========================================================== // Return API // ========================================================== @@ -730,6 +797,12 @@ export function useDSP408( knee: number, threshold: number ) => setCompressor(dsp, outputChannel, ratio, attack, release, knee, threshold), + setLimiter: ( + outputChannel: OutputChannel, + threshold: number, + attack: number, + release: number + ) => setLimiter(dsp, outputChannel, threshold, attack, release), }; } diff --git a/src/pages/DspPage/DspPage.tsx b/src/pages/DspPage/DspPage.tsx index 7a57dec..6f32df8 100644 --- a/src/pages/DspPage/DspPage.tsx +++ b/src/pages/DspPage/DspPage.tsx @@ -8,6 +8,7 @@ import CompressorSection from '../../components/dsp408/CompressorSection/Compres import { DSP408Device } from '../../hooks/useDSP408'; import './DspPage.css'; import GateSection from '../../components/dsp408/GateSection/GateSection'; +import LimiterSection from '../../components/dsp408/LimiterSection/LimiterSection'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; import { InputChannel, OutputChannel } from '../../types/dsp408State'; @@ -183,6 +184,9 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) { case 'compressor': return ; + case 'limiter': + return ; + default: return null; }