diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx
index ff391c8..a7664eb 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -99,7 +99,7 @@ export const ApiDocs: React.FC = () => (
-
+
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index 4564d20..774e08e 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -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 = () => (
-
+
diff --git a/apps/docs/src/sections/Synth.tsx b/apps/docs/src/sections/Synth.tsx
index 06fd3e8..480d03d 100644
--- a/apps/docs/src/sections/Synth.tsx
+++ b/apps/docs/src/sections/Synth.tsx
@@ -123,7 +123,7 @@ export const Synth: React.FC = () => {
const [preset, setPreset] = React.useState(0);
const [params, setParams] = React.useState(INIT);
const [note, setNote] = React.useState(null);
- const [meterDb, setMeterDb] = React.useState(-60);
+ const [meterDb, setMeterDb] = React.useState({ rms: -60, peak: -60 });
const engine = React.useRef(null);
const baseFreq = React.useRef(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"
/>
void;
/** Panel color behind the LEDs. */
faceColor?: string;
label?: string;
@@ -62,6 +80,12 @@ export const Meter: React.FC = ({
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 = ({
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>();
+ 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 = ({
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 = ({
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 = ({
{label && (