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

@ -99,7 +99,7 @@ export const ApiDocs: React.FC = () => (
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." /> <Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." /> <Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." /> <Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout." /> <Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout. Clip LED: showClip (default on), clipThreshold (default max), clipHold (ms or 'latch', click to clear), clipColor, onClip." />
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs, drag + arrow keys, grid, crosshair, Escape cancel, double-click reset." /> <Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs, drag + arrow keys, grid, crosshair, Escape cancel, double-click reset." />
<Row name="<PushButton />" type="switch" desc="Panel button with LED strip: toggle or momentary, controlled/uncontrolled, aria-pressed." /> <Row name="<PushButton />" type="switch" desc="Panel button with LED strip: toggle or momentary, controlled/uncontrolled, aria-pressed." />
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols, chars padding, align, glow, skew." /> <Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols, chars padding, align, glow, skew." />

View file

@ -108,9 +108,11 @@ const MeterDemo: React.FC = () => {
let t = 0; let t = 0;
const id = setInterval(() => { const id = setInterval(() => {
t += 0.09; t += 0.09;
// Occasional hot transient so the clip LED gets to do its job.
const spike = Math.random() < 0.04 ? 9 : 0;
setLevels([ setLevels([
-13 + Math.sin(t) * 6 + Math.random() * 7, -13 + Math.sin(t) * 6 + Math.random() * 7 + spike,
-14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7, -14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7 + spike,
]); ]);
}, 90); }, 90);
return () => clearInterval(id); return () => clearInterval(id);
@ -341,7 +343,7 @@ export const Gallery: React.FC = () => (
</div> </div>
</Card> </Card>
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold={1200} />"> <Card title="Meter" desc="Read-only LED metering with peak hold and a latching clip LED — click it to clear." tag="<Meter clipHold={1500} />">
<MeterDemo /> <MeterDemo />
</Card> </Card>

View file

@ -123,7 +123,7 @@ export const Synth: React.FC = () => {
const [preset, setPreset] = React.useState(0); const [preset, setPreset] = React.useState(0);
const [params, setParams] = React.useState<Params>(INIT); const [params, setParams] = React.useState<Params>(INIT);
const [note, setNote] = React.useState<string | null>(null); const [note, setNote] = React.useState<string | null>(null);
const [meterDb, setMeterDb] = React.useState(-60); const [meterDb, setMeterDb] = React.useState({ rms: -60, peak: -60 });
const engine = React.useRef<Engine | null>(null); const engine = React.useRef<Engine | null>(null);
const baseFreq = React.useRef<number | null>(null); const baseFreq = React.useRef<number | null>(null);
const paramsRef = React.useRef(params); const paramsRef = React.useRef(params);
@ -198,9 +198,16 @@ export const Synth: React.FC = () => {
last = t; last = t;
analyser.getFloatTimeDomainData(buf); analyser.getFloatTimeDomainData(buf);
let sum = 0; let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]; let pk = 0;
for (let i = 0; i < buf.length; i++) {
sum += buf[i] * buf[i];
const a = Math.abs(buf[i]);
if (a > pk) pk = a;
}
const rms = Math.sqrt(sum / buf.length); const rms = Math.sqrt(sum / buf.length);
setMeterDb(rms > 0 ? Math.max(-60, 20 * Math.log10(rms)) : -60); const toDb = (v: number) => (v > 0 ? Math.max(-60, 20 * Math.log10(v)) : -60);
// Bar shows RMS; the clip LED watches sample peaks, like real meters.
setMeterDb({ rms: toDb(rms), peak: toDb(pk) });
}; };
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
@ -209,7 +216,7 @@ export const Synth: React.FC = () => {
engine.current = null; engine.current = null;
baseFreq.current = null; baseFreq.current = null;
void ctx.close(); void ctx.close();
setMeterDb(-60); setMeterDb({ rms: -60, peak: -60 });
setNote(null); setNote(null);
}; };
}, [powered]); }, [powered]);
@ -590,7 +597,8 @@ export const Synth: React.FC = () => {
aria-label="Master volume" aria-label="Master volume"
/> />
<Meter <Meter
value={meterDb} value={meterDb.rms}
clipValue={meterDb.peak}
min={-60} min={-60}
max={0} max={0}
length={120} length={120}

View file

@ -96,7 +96,7 @@ import {
Fader, // linear channel fader, vertical or horizontal Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips) ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
Meter, // read-only LED level meter with peak hold Meter, // read-only LED level meter with peak hold + latching clip LED
XYPad, // two-parameter pad (cutoff/resonance, vector mixing) XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
PushButton, // panel button with LED strip (toggle or momentary) PushButton, // panel button with LED strip (toggle or momentary)
SegmentDisplay, // standalone seven-segment numeric display SegmentDisplay, // standalone seven-segment numeric display

View file

@ -31,6 +31,24 @@ export interface MeterProps {
peakHold?: number | false; peakHold?: number | false;
/** Peak indicator color. Defaults to the peak's zone color. */ /** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string; 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. */ /** Panel color behind the LEDs. */
faceColor?: string; faceColor?: string;
label?: string; label?: string;
@ -62,6 +80,12 @@ export const Meter: React.FC<MeterProps> = ({
offOpacity = 0.1, offOpacity = 0.1,
peakHold = 1200, peakHold = 1200,
peakColor, peakColor,
showClip = true,
clipThreshold,
clipValue,
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
faceColor = '#0b0d0e', faceColor = '#0b0d0e',
label, label,
showValue = false, showValue = false,
@ -88,6 +112,29 @@ export const Meter: React.FC<MeterProps> = ({
return () => clearTimeout(t); return () => clearTimeout(t);
}, [n, peak, peakHold]); }, [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 pad = 4;
const w = vertical ? breadth : length; const w = vertical ? breadth : length;
const h = vertical ? length : breadth; const h = vertical ? length : breadth;
@ -101,7 +148,10 @@ export const Meter: React.FC<MeterProps> = ({
const lit = Math.round(n * segments); const lit = Math.round(n * segments);
const peakIdx = const peakIdx =
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1; 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 gap = Math.min(2.5, slot * 0.35);
const cross = breadth - pad * 2; const cross = breadth - pad * 2;
@ -136,6 +186,7 @@ export const Meter: React.FC<MeterProps> = ({
aria-valuemax={max} aria-valuemax={max}
aria-valuenow={clamp(value, min, max)} aria-valuenow={clamp(value, min, max)}
aria-label={aria['aria-label']} aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', 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"> <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)" /> <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} {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> </svg>
{label && ( {label && (
<span <span