feat: add limiter

This commit is contained in:
2026-08-06 16:15:46 +02:00
parent 56f7aca174
commit e802067056
7 changed files with 506 additions and 2 deletions
+17
View File
@@ -291,3 +291,20 @@ pub async fn set_compressor(
.map_err(|e| e.to_string()) .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<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
+1
View File
@@ -22,6 +22,7 @@ pub fn run() {
commands::set_current_preset_name, commands::set_current_preset_name,
commands::set_gate, commands::set_gate,
commands::set_compressor, commands::set_compressor,
commands::set_limiter,
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())
@@ -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;
}
@@ -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, 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 LimiterParamKey = 'threshold' | 'attack' | 'release';
type LimiterValues = {
threshold: number;
attack: number;
release: number;
};
type SetLimiterFn = (
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
) => Promise<boolean>;
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<LimiterValues>) => {
const next = { ...lim, ...overrides };
return setLimiter(channel, next.threshold, next.attack, next.release);
};
return (
<div className="limiter-panel">
<div className="limiter-panel__title">{outputState.name}</div>
<div className="limiter-panel__grid">
{LIMITER_SLIDER_PARAMS.map((param) => (
<div className={`limiter-panel__cell limiter-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={param.step}
unit={param.unit}
value={lim[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<LimiterValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="limiter-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
// 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<OutputChannel>(outputs[0]);
const [liveLim, setLiveLim] = useState<Partial<Record<OutputChannel, LimiterValues>>>({});
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 (
<>
<div className="limiter-linear-graph-section">
<div className="limiter-graph-panel">
<div className="limiter-graph__title">{activeOutputState.name} - Limiter</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(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();
}}
/>
</div>
<div className="limiter-meter-panel">
<div className="limiter-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="limiter-section">
<div className="limiter-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<LimiterPanel
key={String(output)}
channel={output}
dsp={dsp}
setLimiter={handleSetLimiter}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default LimiterSection;
@@ -170,9 +170,9 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
ctx.clearRect(0, 0, cssW, cssH); ctx.clearRect(0, 0, cssW, cssH);
const plot = { const plot = {
x: pad.left, x: pad.left + cq.w * 2,
y: pad.top, 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), height: Math.max(1, cssH - pad.top - pad.bottom),
}; };
+73
View File
@@ -59,6 +59,12 @@ export type DSP408Device = {
knee: number, knee: number,
threshold: number threshold: number
) => Promise<boolean>; ) => Promise<boolean>;
setLimiter: (
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
) => Promise<boolean>;
}; };
// ============================================================ // ============================================================
@@ -702,6 +708,67 @@ export function useDSP408(
}); });
} }
// ==========================================================
// Limiter
// ==========================================================
async function setLimiter(
dsp: DSP,
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('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 // Return API
// ========================================================== // ==========================================================
@@ -730,6 +797,12 @@ export function useDSP408(
knee: number, knee: number,
threshold: number threshold: number
) => setCompressor(dsp, outputChannel, ratio, attack, release, knee, threshold), ) => setCompressor(dsp, outputChannel, ratio, attack, release, knee, threshold),
setLimiter: (
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
) => setLimiter(dsp, outputChannel, threshold, attack, release),
}; };
} }
+4
View File
@@ -8,6 +8,7 @@ import CompressorSection from '../../components/dsp408/CompressorSection/Compres
import { DSP408Device } from '../../hooks/useDSP408'; import { DSP408Device } from '../../hooks/useDSP408';
import './DspPage.css'; import './DspPage.css';
import GateSection from '../../components/dsp408/GateSection/GateSection'; import GateSection from '../../components/dsp408/GateSection/GateSection';
import LimiterSection from '../../components/dsp408/LimiterSection/LimiterSection';
import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay';
import { InputChannel, OutputChannel } from '../../types/dsp408State'; import { InputChannel, OutputChannel } from '../../types/dsp408State';
@@ -183,6 +184,9 @@ function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) {
case 'compressor': case 'compressor':
return <CompressorSection dsp={dsp} set_compressor={dsp408.setCompressor} />; return <CompressorSection dsp={dsp} set_compressor={dsp408.setCompressor} />;
case 'limiter':
return <LimiterSection dsp={dsp} set_limiter={dsp408.setLimiter} />;
default: default:
return null; return null;
} }