DreamKnob/packages/dreamknob/src/skins/Meter.tsx
Dreamodus 509032f257 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
2026-07-12 20:20:54 -07:00

248 lines
7.6 KiB
TypeScript

import * as React from 'react';
import { clamp, linearTaper } from '../core/math';
import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay';
export interface MeterProps {
/** Level to display. The meter is read-only — always controlled. */
value: number;
min?: number;
max?: number;
taper?: Taper;
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/** Cross-axis size in px. Default: 16. */
breadth?: number;
/** Number of LED segments. Default: 28. */
segments?: number;
/** LED color when no zones are given. */
color?: string;
/** Meter zones, e.g. green / yellow / red by normalized position. */
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number;
/**
* Peak hold time in ms — the highest recent segment stays lit and falls
* back after this long. `false` disables. Default: 1200.
*/
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;
/** Show a seven-segment readout. Default: false. */
showValue?: boolean;
digits?: number;
displayDecimals?: number;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* Read-only LED level meter with peak hold — the display-side sibling of
* LEDFader for VU/output metering.
*/
export const Meter: React.FC<MeterProps> = ({
value,
min = 0,
max = 100,
taper = linearTaper,
orientation = 'vertical',
length = 160,
breadth = 16,
segments = 28,
color = '#3df2ad',
zones,
offOpacity = 0.1,
peakHold = 1200,
peakColor,
showClip = true,
clipThreshold,
clipValue,
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
faceColor = '#0b0d0e',
label,
showValue = false,
digits = 4,
displayDecimals = 1,
labelColor,
className,
style,
...aria
}) => {
const theme = useKnobTheme();
const vertical = orientation === 'vertical';
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
// Peak: track the highest recent level, fall back after `peakHold` ms.
const [peak, setPeak] = React.useState(n);
React.useEffect(() => {
if (peakHold === false) return;
if (n >= peak) {
setPeak(n);
return;
}
const t = setTimeout(() => setPeak(n), peakHold);
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;
const zoneList: readonly LEDFaderZone[] = React.useMemo(
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
[zones, color],
);
const zoneFor = (t: number): string =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
const lit = Math.round(n * segments);
const peakIdx =
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
// 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;
const leds: React.ReactNode[] = [];
for (let i = 0; i < segments; i++) {
const zone = zoneFor((i + 1) / segments);
const isPeak = i === peakIdx && i >= lit;
const on = i < lit || isPeak;
const fill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
leds.push(
vertical ? (
<rect
key={i}
x={pad}
y={length - pad - (i + 1) * slot + gap / 2}
width={cross}
height={slot - gap}
{...common}
/>
) : (
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} />
),
);
}
return (
<div
className={className}
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamp(value, min, max)}
aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 5,
width: w,
...style,
}}
>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<SegmentDisplay
value={clamp(value, min, max)}
digits={digits}
decimals={displayDecimals}
height={11}
color={zoneFor(n)}
background="none"
ghostOpacity={0.06}
/>
</div>
)}
<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
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: labelColor ?? theme.label,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)}
</div>
);
};