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:
parent
d54eecaeaf
commit
509032f257
5 changed files with 88 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue