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="<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="<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="<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." />

View file

@ -108,9 +108,11 @@ const MeterDemo: React.FC = () => {
let t = 0;
const id = setInterval(() => {
t += 0.09;
// Occasional hot transient so the clip LED gets to do its job.
const spike = Math.random() < 0.04 ? 9 : 0;
setLevels([
-13 + Math.sin(t) * 6 + Math.random() * 7,
-14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7,
-13 + Math.sin(t) * 6 + Math.random() * 7 + spike,
-14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7 + spike,
]);
}, 90);
return () => clearInterval(id);
@ -341,7 +343,7 @@ export const Gallery: React.FC = () => (
</div>
</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 />
</Card>

View file

@ -123,7 +123,7 @@ export const Synth: React.FC = () => {
const [preset, setPreset] = React.useState(0);
const [params, setParams] = React.useState<Params>(INIT);
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 baseFreq = React.useRef<number | null>(null);
const paramsRef = React.useRef(params);
@ -198,9 +198,16 @@ export const Synth: React.FC = () => {
last = t;
analyser.getFloatTimeDomainData(buf);
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);
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);
@ -209,7 +216,7 @@ export const Synth: React.FC = () => {
engine.current = null;
baseFreq.current = null;
void ctx.close();
setMeterDb(-60);
setMeterDb({ rms: -60, peak: -60 });
setNote(null);
};
}, [powered]);
@ -590,7 +597,8 @@ export const Synth: React.FC = () => {
aria-label="Master volume"
/>
<Meter
value={meterDb}
value={meterDb.rms}
clipValue={meterDb.peak}
min={-60}
max={0}
length={120}