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 && (