DSP rack
Three grip faders (EQ tilt, compression, saturation): a tick-marked track and a rounded grip that follows the hand 1:1, with a unity tick on the bipolar EQ.
Loading preview…
Show code
"use client";
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { DspParams } from "@/lib/openkit/audio-engine";
import { cn } from "@/lib/utils";
// Live DSP: EQ tilt, compression and saturation as three grip faders. Each
// fader is a label and readout over a tick-marked track with a rounded grip.
// The grip follows the pointer 1:1 and keyboard steps snap: no spring, no
// lag, no overshoot. Audio and readout update on the same frame as the hand.
// EQ is bipolar (warm ← flat → bright), so its centre tick marks unity.
interface SliderDef {
key: keyof DspParams;
label: string;
/** Centre is the neutral value (marked with a taller tick). */
bipolar?: boolean;
/** Human-readable readout for the current 0..1 value. */
format: (v: number) => string;
}
const SLIDERS: SliderDef[] = [
{
key: "eq",
label: "EQ",
bipolar: true,
format: (v) => {
const tilt = Math.round((v - 0.5) * 200);
if (tilt === 0) return "Flat";
return tilt > 0 ? `+${tilt} bright` : `${-tilt} warm`;
},
},
{ key: "comp", label: "Compression", format: (v) => `${Math.round(v * 100)}%` },
{ key: "sat", label: "Saturation", format: (v) => `${Math.round(v * 100)}%` },
];
const DEFAULTS: DspParams = { eq: 0.5, comp: 0, sat: 0 };
const STEP = 0.05;
const BIG_STEP = 0.2;
const TICKS = 17; // odd, so the bipolar centre lands on a tick
const GRIP_W = 14;
const INSET = 8; // grip travel stays clear of the track's rounded ends
export function DspRack({
onChange,
initial,
controls,
}: {
onChange: (params: Partial<DspParams>) => void;
/** Starting values (0..1); EQ 0.5 is flat. */
initial?: Partial<DspParams>;
/** Which faders to show, in order. Defaults to all three. */
controls?: readonly (keyof DspParams)[];
}) {
const sliders = controls
? controls.flatMap((key) => SLIDERS.filter((def) => def.key === key))
: SLIDERS;
return (
<div className="flex w-full flex-col gap-4">
{sliders.map((def) => (
<Fader
key={def.key}
def={def}
initial={initial?.[def.key] ?? DEFAULTS[def.key]}
onChange={(v) => onChange({ [def.key]: v })}
/>
))}
</div>
);
}
function Fader({
def,
initial,
onChange,
}: {
def: SliderDef;
initial: number;
onChange: (value: number) => void;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(initial);
const [dragging, setDragging] = useState(false);
const [travel, setTravel] = useState(0);
// Grip travel in px, so the grip moves with transform (no layout per frame).
useEffect(() => {
const el = trackRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setTravel(Math.max(0, entry.contentRect.width - INSET * 2 - GRIP_W));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const setTo = (next: number) => {
const clamped = Math.round(Math.min(1, Math.max(0, next)) * 100) / 100;
if (clamped === value) return;
setValue(clamped);
onChange(clamped);
};
const fromClientX = (clientX: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setTo((clientX - rect.left - INSET - GRIP_W / 2) / (rect.width - INSET * 2 - GRIP_W));
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
fromClientX(e.clientX);
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) fromClientX(e.clientX);
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
setDragging(false);
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? BIG_STEP : STEP;
const keys: Record<string, number> = {
ArrowRight: value + step,
ArrowUp: value + step,
ArrowLeft: value - step,
ArrowDown: value - step,
PageUp: value + BIG_STEP,
PageDown: value - BIG_STEP,
Home: 0,
End: 1,
};
if (!(e.key in keys)) return;
e.preventDefault();
setTo(keys[e.key]);
};
const centre = (TICKS - 1) / 2;
return (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between px-0.5">
<span className="text-sm text-foreground">{def.label}</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">{def.format(value)}</span>
</div>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-label={def.label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
aria-valuetext={def.format(value)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
onDoubleClick={() => setTo(DEFAULTS[def.key])}
data-dragging={dragging ? "" : undefined}
className={cn(
"group relative h-9 w-full cursor-ew-resize touch-none select-none rounded-xl bg-muted outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{/* Ticks: evenly spaced across the grip's travel. */}
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 flex items-center justify-between"
style={{ left: INSET + GRIP_W / 2 - 1, right: INSET + GRIP_W / 2 - 1 }}
>
{Array.from({ length: TICKS }, (_, i) => (
<span
key={i}
className={cn(
"w-0.5 rounded-full",
def.bipolar && i === centre ? "h-4 bg-muted-foreground/70" : "h-2.5 bg-muted-foreground/30",
)}
/>
))}
</span>
{/* Grip: a rounded bar with a hairline edge; brightens while held. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1.5 bottom-1.5 left-0 rounded-[5px] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.25),0_1px_2px_rgb(0_0_0/0.35)]",
"bg-muted-foreground transition-colors duration-150 group-hover:bg-foreground/80 group-data-[dragging]:bg-foreground",
)}
style={{ width: GRIP_W, transform: `translateX(${INSET + value * travel}px)` }}
/>
</div>
</div>
);
}