feat: add limiter
This commit is contained in:
@@ -291,3 +291,20 @@ 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<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_limiter(channel, threshold, attack, release)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
@@ -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!())
|
||||
|
||||
@@ -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);
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ export type DSP408Device = {
|
||||
knee: number,
|
||||
threshold: number
|
||||
) => 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
|
||||
// ==========================================================
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <CompressorSection dsp={dsp} set_compressor={dsp408.setCompressor} />;
|
||||
|
||||
case 'limiter':
|
||||
return <LimiterSection dsp={dsp} set_limiter={dsp408.setLimiter} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user