Meter: proper clipping indicators

- Dedicated clip LED at the hot end (default on): lights when the
  signal reaches clipThreshold (default max), holds for clipHold ms or
  latches until clicked ('latch'); clipColor, onClip callback,
  data-clipped root attribute, data-part=clip for CSS
- clipValue prop: separate signal for clip detection so the bar can
  show RMS while the LED watches sample peaks, like real meters
- Synth: analyser loop now computes sample peaks alongside RMS and
  feeds them to the clip detector - overdriving the synth genuinely
  trips the LED
- Gallery meter demo throws occasional hot transients to exercise it;
  API docs and README updated
This commit is contained in:
Dreamodus 2026-07-12 20:20:54 -07:00
parent d54eecaeaf
commit 509032f257
5 changed files with 88 additions and 11 deletions

View file

@ -31,6 +31,24 @@ export interface MeterProps {
peakHold?: number | false;
/** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string;
/** Dedicated clip LED at the hot end of the meter. Default: true. */
showClip?: boolean;
/** Value at/above which the clip LED lights. Default: `max`. */
clipThreshold?: number;
/**
* Signal used for clip detection, when it differs from the displayed
* level e.g. show RMS on the bar but clip on sample peaks. Defaults
* to `value`.
*/
clipValue?: number;
/**
* How long the clip LED stays lit in ms, or 'latch' to stay lit until
* clicked. Default: 1500.
*/
clipHold?: number | 'latch';
clipColor?: string;
/** Fired when the signal first crosses the clip threshold. */
onClip?: (value: number) => void;
/** Panel color behind the LEDs. */
faceColor?: string;
label?: string;
@ -62,6 +80,12 @@ export const Meter: React.FC<MeterProps> = ({
offOpacity = 0.1,
peakHold = 1200,
peakColor,
showClip = true,
clipThreshold,
clipValue,
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
faceColor = '#0b0d0e',
label,
showValue = false,
@ -88,6 +112,29 @@ export const Meter: React.FC<MeterProps> = ({
return () => clearTimeout(t);
}, [n, peak, peakHold]);
// Clip: latch a dedicated LED when the raw (unclamped) signal reaches the
// threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode.
const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
const wasOver = React.useRef(false);
const onClipRef = React.useRef(onClip);
onClipRef.current = onClip;
React.useEffect(() => {
if (!showClip) return;
const signal = clipValue ?? value;
const over = signal >= (clipThreshold ?? max) - 1e-9;
if (over) {
if (!wasOver.current) onClipRef.current?.(signal);
setClipped(true);
if (clipHold !== 'latch') {
clearTimeout(clipTimer.current);
clipTimer.current = setTimeout(() => setClipped(false), clipHold);
}
}
wasOver.current = over;
}, [value, clipValue, showClip, clipThreshold, max, clipHold]);
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
const pad = 4;
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
@ -101,7 +148,10 @@ export const Meter: React.FC<MeterProps> = ({
const lit = Math.round(n * segments);
const peakIdx =
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
const slot = (length - pad * 2) / segments;
// Reserve room at the hot end for the clip LED (7px LED + 3px gap).
const clipLed = 7;
const clipSpan = showClip ? clipLed + 3 : 0;
const slot = (length - pad * 2 - clipSpan) / segments;
const gap = Math.min(2.5, slot * 0.35);
const cross = breadth - pad * 2;
@ -136,6 +186,7 @@ export const Meter: React.FC<MeterProps> = ({
aria-valuemax={max}
aria-valuenow={clamp(value, min, max)}
aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined}
style={{
display: 'inline-flex',
flexDirection: 'column',
@ -161,6 +212,22 @@ export const Meter: React.FC<MeterProps> = ({
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
{leds}
{showClip && (
<rect
data-part="clip"
x={vertical ? pad : length - pad - clipLed}
y={vertical ? pad : pad}
width={vertical ? cross : clipLed}
height={vertical ? clipLed : cross}
rx={1.5}
fill={clipColor}
opacity={clipped ? 1 : 0.14}
onClick={() => setClipped(false)}
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
>
<title>{clipped ? 'Clip! Click to clear' : 'Clip indicator'}</title>
</rect>
)}
</svg>
{label && (
<span